@keith9681/dsh-sidebar 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor tab host: the single FILES WINDOW. It resolves a file's
|
|
3
|
+
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
|
|
4
|
+
* per the matched viewer's fetch strategy, and renders its component — or
|
|
5
|
+
* the shared download pane when nothing can render the file. A tab without
|
|
6
|
+
* a path (the seeded "Files" home) renders an empty-state hint instead of
|
|
7
|
+
* the viewer loading flow; that path-less window IS the file explorer.
|
|
8
|
+
*
|
|
9
|
+
* The chrome depends on the `editorExplorer` mode (read reactively so
|
|
10
|
+
* toggling it re-renders without a reload):
|
|
11
|
+
* - merged (in-place): tree click / path-input Enter switch the CURRENT
|
|
12
|
+
* tab in place (updateTab rewrites path/title; the tab keeps its id and
|
|
13
|
+
* meta, so treeOpen/treeWidth survive the switch);
|
|
14
|
+
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
|
|
15
|
+
* and a PATH-LESS window is the standalone explorer — it renders ONLY
|
|
16
|
+
* the tree panel (search + FileTree, full-window), no editor chrome.
|
|
17
|
+
* Editor tabs (with a path) keep the full chrome in both modes.
|
|
18
|
+
* The tree's context menu offers the explicit escapes in both modes: open
|
|
19
|
+
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
|
|
20
|
+
* rightward split of the current pane).
|
|
21
|
+
*
|
|
22
|
+
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
|
23
|
+
* editor-load.ts); this component only wires it to the host APIs.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
26
|
+
import { createElement } from 'react'
|
|
27
|
+
import clsx from 'clsx'
|
|
28
|
+
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
29
|
+
import type { Context } from '../context-types.ts'
|
|
30
|
+
import { api, mediaUrl, type SessionScope } from './api.ts'
|
|
31
|
+
import { BinaryDownload } from './binary-download.tsx'
|
|
32
|
+
import { isSessionEditable, setSessionEditable, subscribeSessionEditable } from './editable-state.ts'
|
|
33
|
+
import { IconLockClosedOutline16, IconLockOpenOutline16 } from './icons.tsx'
|
|
34
|
+
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
|
35
|
+
import { baseName } from './FileTree.tsx'
|
|
36
|
+
import { createFrameBatcher } from './frame-batcher.ts'
|
|
37
|
+
import { openSidebarFile } from './intercept.tsx'
|
|
38
|
+
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
|
|
39
|
+
import { updatePluginSettings } from './plugin-settings.ts'
|
|
40
|
+
import { TreePanel } from './TreePanel.tsx'
|
|
41
|
+
import { t } from './locales.ts'
|
|
42
|
+
import { relativeTo } from './paths.ts'
|
|
43
|
+
import { resolveSidebarPath } from './produced-files.ts'
|
|
44
|
+
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
|
45
|
+
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
|
46
|
+
import css from './sidebar.module.css'
|
|
47
|
+
|
|
48
|
+
type EditorLoad =
|
|
49
|
+
| { status: 'loading' }
|
|
50
|
+
| { status: 'error'; message: string }
|
|
51
|
+
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
|
52
|
+
| { status: 'binary' }
|
|
53
|
+
|
|
54
|
+
/** The docked tree panel's width bounds (drag-resize clamps into them). */
|
|
55
|
+
const TREE_WIDTH_DEFAULT = 240
|
|
56
|
+
const TREE_WIDTH_MIN = 160
|
|
57
|
+
const TREE_WIDTH_MAX = 480
|
|
58
|
+
|
|
59
|
+
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
|
|
60
|
+
* would change identity every snapshot and loop useSyncExternalStore). */
|
|
61
|
+
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
|
|
62
|
+
|
|
63
|
+
/** The tab's persisted meta object (a malformed meta reads as empty). */
|
|
64
|
+
function metaOf(tab: SidebarTab): Record<string, unknown> {
|
|
65
|
+
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
|
|
66
|
+
? tab.meta as Record<string, unknown>
|
|
67
|
+
: {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read the persisted tree-panel flag of one editor tab: an explicit
|
|
71
|
+
* boolean meta wins; otherwise path-less tabs (the seeded home) default
|
|
72
|
+
* open and file tabs default closed. */
|
|
73
|
+
function treeOpenOf(tab: SidebarTab): boolean {
|
|
74
|
+
const treeOpen = metaOf(tab).treeOpen
|
|
75
|
+
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read the persisted tree-panel width (clamped; default 240). */
|
|
79
|
+
function treeWidthOf(tab: SidebarTab): number {
|
|
80
|
+
const width = metaOf(tab).treeWidth
|
|
81
|
+
return typeof width === 'number' && Number.isFinite(width)
|
|
82
|
+
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
|
|
83
|
+
: TREE_WIDTH_DEFAULT
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Merge a patch into the tab's persisted meta (rides the layout). */
|
|
87
|
+
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
|
|
88
|
+
ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Clamp one dock width into the contract range. */
|
|
92
|
+
function clampTreeWidth(value: number): number {
|
|
93
|
+
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function EditorHost(props: {
|
|
97
|
+
ctx: Context
|
|
98
|
+
store: SidebarStore
|
|
99
|
+
scope: SessionScope
|
|
100
|
+
tab: SidebarTab
|
|
101
|
+
expanded: string[]
|
|
102
|
+
onToggleDir: (path: string) => void
|
|
103
|
+
onReferenceFile: (path: string) => void
|
|
104
|
+
}) {
|
|
105
|
+
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
|
106
|
+
const path = tab.path ?? ''
|
|
107
|
+
const title = tab.title
|
|
108
|
+
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
|
109
|
+
|
|
110
|
+
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
|
|
111
|
+
// reload. The snapshot is the bare boolean so unrelated store churn never
|
|
112
|
+
// re-renders the editor.
|
|
113
|
+
const inPlace = useSyncExternalStore(
|
|
114
|
+
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
|
115
|
+
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
|
|
116
|
+
)
|
|
117
|
+
// The file tree's "open with" configuration (pluginSettings['editor']): a
|
|
118
|
+
// blob subscription, so a pin click or a settings-page edit re-renders the
|
|
119
|
+
// menu immediately. The parsed config also drives which targets are shown
|
|
120
|
+
// (SSH mode hides the host-local ones).
|
|
121
|
+
const editorBlob = useSyncExternalStore(
|
|
122
|
+
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
|
123
|
+
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
|
|
124
|
+
)
|
|
125
|
+
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
|
126
|
+
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
|
127
|
+
// A path-less tab shows the empty-state hint in merged mode — and in split
|
|
128
|
+
// mode it is the standalone explorer (tree-only, see the render below).
|
|
129
|
+
const showEmpty = path === ''
|
|
130
|
+
const treeOnly = showEmpty && !inPlace
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Open a file from THIS window (tree click / search row / path input):
|
|
134
|
+
* merged mode switches this tab in place (stable id, meta survives);
|
|
135
|
+
* split mode opens a per-path dedupe tab through openSidebarFile.
|
|
136
|
+
*/
|
|
137
|
+
const openFile = (absolute: string): void => {
|
|
138
|
+
if (inPlace) {
|
|
139
|
+
ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
|
|
140
|
+
} else {
|
|
141
|
+
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The context menu's explicit "new tab" escape (per-path dedupe). */
|
|
146
|
+
const openFileNewTab = (absolute: string): void => {
|
|
147
|
+
openSidebarFile(ctx, store, scope.sessionId, absolute)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The context menu's "open to the side": a fresh editor tab (uid id — the
|
|
152
|
+
* `'editor:' + path` convention would clash with the id safety net on a
|
|
153
|
+
* second side-open of the same file) in a rightward split of THIS pane.
|
|
154
|
+
*/
|
|
155
|
+
const openFileSide = (absolute: string): void => {
|
|
156
|
+
store.reduce((state) => {
|
|
157
|
+
const key = treeOf(state, tab.id)
|
|
158
|
+
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
|
|
159
|
+
const fresh: SidebarTab = {
|
|
160
|
+
id: mintTabId(),
|
|
161
|
+
type: 'editor',
|
|
162
|
+
title: baseName(absolute),
|
|
163
|
+
path: absolute,
|
|
164
|
+
meta: { treeOpen: false },
|
|
165
|
+
}
|
|
166
|
+
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
|
|
167
|
+
return { ...state, [key]: node, activePane: leafId }
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The context menu's "open with" action: reveal the path in the OS file
|
|
172
|
+
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
|
|
173
|
+
* form for VSCode-family editors in remote mode) to the host's external
|
|
174
|
+
* opener. Failures are logged only — a missing handler is the OS's
|
|
175
|
+
* dialog, not a sidebar error. */
|
|
176
|
+
const openWith = (targetId: string, absolute: string): void => {
|
|
177
|
+
const target = openWithTargets.find(item => item.id === targetId)
|
|
178
|
+
if (target === undefined) return
|
|
179
|
+
if (target.kind === 'reveal') {
|
|
180
|
+
void api.openExternal({ action: 'reveal', path: absolute }).catch(
|
|
181
|
+
(error: unknown) => { console.error('open external failed', error) },
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
const url = openWithUrl(target, absolute, openWithConfig)
|
|
186
|
+
if (url === undefined) return
|
|
187
|
+
void api.openExternal({ action: 'url', url }).catch(
|
|
188
|
+
(error: unknown) => { console.error('open external failed', error) },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Toggle one target's pinned state. The write is serialized (see
|
|
193
|
+
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
|
|
194
|
+
const toggleOpenWithPin = (targetId: string): void => {
|
|
195
|
+
updatePluginSettings(store, 'editor', (blob) => {
|
|
196
|
+
const config = parseOpenWithConfig(blob.openWith)
|
|
197
|
+
const pinned = config.pinned.includes(targetId)
|
|
198
|
+
? config.pinned.filter(id => id !== targetId)
|
|
199
|
+
: [...config.pinned, targetId]
|
|
200
|
+
return { ...blob, openWith: { ...config, pinned } }
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
|
205
|
+
// its state and registers its commands (both null/absent for viewers
|
|
206
|
+
// without a toolbar — image, pdf, binary download).
|
|
207
|
+
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
|
|
208
|
+
const controlsRef = useRef<EditorToolbarControls | null>(null)
|
|
209
|
+
const onToolbarState = useCallback((next: EditorToolbarState) => {
|
|
210
|
+
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
|
|
211
|
+
}, [])
|
|
212
|
+
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
|
|
213
|
+
controlsRef.current = controls
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
// The docked panel's drag-resize: pointer capture on the handle itself
|
|
217
|
+
// (no window listeners — the captured pointer keeps tracking even off the
|
|
218
|
+
// handle). Local width while dragging, persisted into meta.treeWidth on
|
|
219
|
+
// release. The panel docks right, so dragging LEFT widens it.
|
|
220
|
+
// Moves are BATCHED per frame (createFrameBatcher): applying every
|
|
221
|
+
// pointermove is a setState that re-renders this host AND the editor
|
|
222
|
+
// viewer below it (CodeMirror re-lays out on the width change) at event
|
|
223
|
+
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
|
|
224
|
+
// the latest width once per frame; release flushes it and commits.
|
|
225
|
+
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
|
226
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
|
227
|
+
const pendingWidthRef = useRef(0)
|
|
228
|
+
const dragBatcher = useRef(createFrameBatcher()).current
|
|
229
|
+
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
|
|
230
|
+
const treeWidth = dragWidth ?? treeWidthOf(tab)
|
|
231
|
+
|
|
232
|
+
const onResizeStart = (event: React.PointerEvent): void => {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
|
|
235
|
+
event.currentTarget.setPointerCapture?.(event.pointerId)
|
|
236
|
+
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
|
|
237
|
+
}
|
|
238
|
+
const onResizeMove = (event: React.PointerEvent): void => {
|
|
239
|
+
const drag = dragRef.current
|
|
240
|
+
if (drag === null) return
|
|
241
|
+
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
|
242
|
+
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
|
|
243
|
+
}
|
|
244
|
+
const onResizeEnd = (event: React.PointerEvent): void => {
|
|
245
|
+
const drag = dragRef.current
|
|
246
|
+
if (drag === null) return
|
|
247
|
+
// Flush the last pending frame (a release can land with the final move
|
|
248
|
+
// still queued; without the flush a stray frame would re-apply the
|
|
249
|
+
// drag width AFTER the null below). Both setStates batch into this same
|
|
250
|
+
// event, so the committed treeWidth wins visually.
|
|
251
|
+
dragBatcher.flushNow()
|
|
252
|
+
dragRef.current = null
|
|
253
|
+
setDragWidth(null)
|
|
254
|
+
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
|
255
|
+
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
// A (re)load or a path-less tab clears any hoisted toolbar state — the
|
|
260
|
+
// fresh viewer re-registers its own.
|
|
261
|
+
setToolbar(null)
|
|
262
|
+
// The seeded home tab (no path) never loads a viewer — the empty-state
|
|
263
|
+
// hint renders until the user picks a file.
|
|
264
|
+
if (showEmpty) return
|
|
265
|
+
let cancelled = false
|
|
266
|
+
// Aborts the matched viewer's `load` when the editor tears down (tab
|
|
267
|
+
// closed, path changed, session switched) or re-matches the viewer.
|
|
268
|
+
const controller = new AbortController()
|
|
269
|
+
setLoad({ status: 'loading' })
|
|
270
|
+
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
|
271
|
+
const apply = (action: EditorLoadAction): void => {
|
|
272
|
+
if (cancelled) return
|
|
273
|
+
switch (action.kind) {
|
|
274
|
+
case 'binary':
|
|
275
|
+
setLoad({ status: 'binary' })
|
|
276
|
+
return
|
|
277
|
+
case 'render':
|
|
278
|
+
setLoad({
|
|
279
|
+
status: 'ready',
|
|
280
|
+
viewer: action.viewer,
|
|
281
|
+
content: action.content,
|
|
282
|
+
truncated: action.truncated,
|
|
283
|
+
mediaUrl: action.mediaUrl,
|
|
284
|
+
customData: action.customData,
|
|
285
|
+
})
|
|
286
|
+
return
|
|
287
|
+
case 'customLoad':
|
|
288
|
+
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
|
|
289
|
+
if (cancelled) return
|
|
290
|
+
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
|
291
|
+
}).catch((error: unknown) => {
|
|
292
|
+
if (cancelled) return
|
|
293
|
+
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
case 'fetchFsRead':
|
|
297
|
+
api.fsRead(scope, path).then((result) => {
|
|
298
|
+
if (cancelled) return
|
|
299
|
+
// Binary reads carry the head bytes for the detect re-match.
|
|
300
|
+
const outcome = planFsReadOutcome(action.viewer, {
|
|
301
|
+
binary: result.kind === 'binary',
|
|
302
|
+
content: result.kind === 'text' ? result.content : '',
|
|
303
|
+
truncated: result.truncated,
|
|
304
|
+
head: result.kind === 'binary' ? result.head : undefined,
|
|
305
|
+
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
|
306
|
+
apply(outcome)
|
|
307
|
+
}).catch((error: unknown) => {
|
|
308
|
+
if (cancelled) return
|
|
309
|
+
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
310
|
+
})
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
|
315
|
+
return () => { cancelled = true; controller.abort() }
|
|
316
|
+
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
|
|
317
|
+
|
|
318
|
+
const treeOpen = treeOpenOf(tab)
|
|
319
|
+
/** Persist the panel flag on the tab (survives reloads with the layout). */
|
|
320
|
+
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
|
|
321
|
+
/** Whether THIS session may write (read-only by default; the toggle flips it). */
|
|
322
|
+
const [writable, setWritable] = useState(() => isSessionEditable(scope.sessionId))
|
|
323
|
+
// Follow the shared per-session write permission so the merged host
|
|
324
|
+
// toolbar and the editor stay in lockstep with the git panel.
|
|
325
|
+
useEffect(() => subscribeSessionEditable(scope.sessionId, setWritable), [scope.sessionId])
|
|
326
|
+
/** Flip this session's write permission: local first, then the host (a
|
|
327
|
+
* host failure rolls the flag back so UI and enforcement agree). */
|
|
328
|
+
const toggleWritable = (): void => {
|
|
329
|
+
const next = !writable
|
|
330
|
+
setSessionEditable(scope.sessionId, next)
|
|
331
|
+
void api.configSetEditable(scope, next).catch(() => {
|
|
332
|
+
setSessionEditable(scope.sessionId, !next)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
const saveLabel = toolbar === null ? ''
|
|
336
|
+
: toolbar.saveState === 'saving' ? t('loading')
|
|
337
|
+
: toolbar.saveState === 'saved' ? t('saved')
|
|
338
|
+
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
|
|
339
|
+
|
|
340
|
+
// Split mode: the path-less window IS the standalone explorer — the tree
|
|
341
|
+
// panel fills the whole tab (search + FileTree, full form), no editor
|
|
342
|
+
// chrome. File opens land in new per-path tabs through openFile above.
|
|
343
|
+
if (treeOnly) {
|
|
344
|
+
return (
|
|
345
|
+
<div className={css.editor}>
|
|
346
|
+
<TreePanel
|
|
347
|
+
full
|
|
348
|
+
sessionId={scope.sessionId}
|
|
349
|
+
cwd={scope.cwd}
|
|
350
|
+
expanded={expanded}
|
|
351
|
+
onToggle={onToggleDir}
|
|
352
|
+
onOpenFile={openFile}
|
|
353
|
+
onOpenFileNewTab={openFileNewTab}
|
|
354
|
+
onOpenFileSide={openFileSide}
|
|
355
|
+
openWithTargets={openWithTargets}
|
|
356
|
+
openWithPinned={openWithConfig.pinned}
|
|
357
|
+
openWithSsh={openWithSshActive(openWithConfig)}
|
|
358
|
+
onOpenWith={openWith}
|
|
359
|
+
onToggleOpenWithPin={toggleOpenWithPin}
|
|
360
|
+
onReferenceFile={onReferenceFile}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div className={css.editor}>
|
|
368
|
+
<div className={css.editorHeader}>
|
|
369
|
+
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
|
|
370
|
+
{toolbar?.modes === true && (
|
|
371
|
+
<div className={css.editorModeToggle}>
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
|
|
375
|
+
onClick={() => { controlsRef.current?.setMode('preview') }}
|
|
376
|
+
>
|
|
377
|
+
{t('preview')}
|
|
378
|
+
</button>
|
|
379
|
+
{writable && (
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
|
|
383
|
+
onClick={() => { controlsRef.current?.setMode('edit') }}
|
|
384
|
+
>
|
|
385
|
+
{t('edit')}
|
|
386
|
+
</button>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
|
|
391
|
+
{toolbar?.editable === true && (
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
className={css.iconButton}
|
|
395
|
+
aria-label={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
|
|
396
|
+
title={writable ? t('switchReadOnlyTitle') : t('switchEditableTitle')}
|
|
397
|
+
onClick={toggleWritable}
|
|
398
|
+
>
|
|
399
|
+
{writable ? <IconLockOpenOutline16 size={14} /> : <IconLockClosedOutline16 size={14} />}
|
|
400
|
+
</button>
|
|
401
|
+
)}
|
|
402
|
+
{toolbar?.editable === true && writable && (
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
className={css.iconButton}
|
|
406
|
+
aria-label={t('save')}
|
|
407
|
+
title={`${t('save')} (Ctrl/Cmd+S)`}
|
|
408
|
+
onClick={() => { controlsRef.current?.save() }}
|
|
409
|
+
>
|
|
410
|
+
<IconCheckOutline16 size={14} />
|
|
411
|
+
</button>
|
|
412
|
+
)}
|
|
413
|
+
{saveLabel !== '' && (
|
|
414
|
+
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
|
|
415
|
+
)}
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
|
|
419
|
+
aria-label={t('editorTreeToggle')}
|
|
420
|
+
title={t('editorTreeToggle')}
|
|
421
|
+
aria-pressed={treeOpen}
|
|
422
|
+
onClick={toggleTree}
|
|
423
|
+
>
|
|
424
|
+
<IconFolderOpen16 size={14} />
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<div className={css.editorBody}>
|
|
428
|
+
<div className={css.editorMain}>
|
|
429
|
+
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
|
|
430
|
+
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
|
431
|
+
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
|
432
|
+
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
|
433
|
+
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
|
|
434
|
+
ctx, store, scope, path, title,
|
|
435
|
+
viewerId: load.viewer.id,
|
|
436
|
+
content: load.content,
|
|
437
|
+
truncated: load.truncated,
|
|
438
|
+
mediaUrl: load.mediaUrl,
|
|
439
|
+
customData: load.customData,
|
|
440
|
+
// The viewer's toolbar always hoists into this host's header.
|
|
441
|
+
toolbar: 'host',
|
|
442
|
+
onToolbarState,
|
|
443
|
+
onToolbarControls,
|
|
444
|
+
})}
|
|
445
|
+
</div>
|
|
446
|
+
{treeOpen && (
|
|
447
|
+
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
|
|
448
|
+
<div
|
|
449
|
+
className={css.editorTreeResize}
|
|
450
|
+
role="separator"
|
|
451
|
+
aria-orientation="vertical"
|
|
452
|
+
aria-label={t('editorTreeToggle')}
|
|
453
|
+
onPointerDown={onResizeStart}
|
|
454
|
+
onPointerMove={onResizeMove}
|
|
455
|
+
onPointerUp={onResizeEnd}
|
|
456
|
+
onPointerCancel={onResizeEnd}
|
|
457
|
+
/>
|
|
458
|
+
<TreePanel
|
|
459
|
+
sessionId={scope.sessionId}
|
|
460
|
+
cwd={scope.cwd}
|
|
461
|
+
expanded={expanded}
|
|
462
|
+
onToggle={onToggleDir}
|
|
463
|
+
onOpenFile={openFile}
|
|
464
|
+
onOpenFileNewTab={openFileNewTab}
|
|
465
|
+
onOpenFileSide={openFileSide}
|
|
466
|
+
openWithTargets={openWithTargets}
|
|
467
|
+
openWithPinned={openWithConfig.pinned}
|
|
468
|
+
openWithSsh={openWithSshActive(openWithConfig)}
|
|
469
|
+
onOpenWith={openWith}
|
|
470
|
+
onToggleOpenWithPin={toggleOpenWithPin}
|
|
471
|
+
onReferenceFile={onReferenceFile}
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The header's path input: shows the current file relative to the session
|
|
482
|
+
* cwd (absolute when outside it). Enter resolves the typed path (relative
|
|
483
|
+
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
|
|
484
|
+
* and opens it through the parent's mode-aware open (in-place switch or a
|
|
485
|
+
* per-path dedupe tab); Escape/blur restores the current value. The parent
|
|
486
|
+
* keys it by `path` so an in-place switch remounts and reseeds the draft.
|
|
487
|
+
*/
|
|
488
|
+
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
|
|
489
|
+
const { path, cwd, onOpen } = props
|
|
490
|
+
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
|
|
491
|
+
const [value, setValue] = useState(display)
|
|
492
|
+
|
|
493
|
+
const commit = (): void => {
|
|
494
|
+
const input = value.trim()
|
|
495
|
+
if (input === '' || input === display) {
|
|
496
|
+
setValue(display)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
onOpen(resolveSidebarPath(cwd, input))
|
|
500
|
+
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
|
|
501
|
+
// path stays, so the input falls back to its own display value. (Merged
|
|
502
|
+
// mode remounts this input on the new path; the reset is harmless.)
|
|
503
|
+
setValue(display)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<input
|
|
508
|
+
className={css.editorPathInput}
|
|
509
|
+
value={value}
|
|
510
|
+
placeholder={t('editorPathPlaceholder')}
|
|
511
|
+
title={path}
|
|
512
|
+
spellCheck={false}
|
|
513
|
+
onChange={(event) => { setValue(event.target.value) }}
|
|
514
|
+
onKeyDown={(event) => {
|
|
515
|
+
if (event.key === 'Enter') {
|
|
516
|
+
event.preventDefault()
|
|
517
|
+
commit()
|
|
518
|
+
} else if (event.key === 'Escape') {
|
|
519
|
+
setValue(display)
|
|
520
|
+
}
|
|
521
|
+
}}
|
|
522
|
+
onBlur={() => { setValue(display) }}
|
|
523
|
+
/>
|
|
524
|
+
)
|
|
525
|
+
}
|