@chatcode/cco-better-sidebar 0.18.0-alpha.0
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 +554 -0
- package/README_EN.md +563 -0
- package/assets/logo.svg +1 -0
- package/cordis.patch.yml +79 -0
- package/lib/client-editor.js +55744 -0
- package/lib/client-mermaid.js +208256 -0
- package/lib/client-registry.js +30378 -0
- package/lib/client-terminal.js +16894 -0
- package/lib/client.js +30378 -0
- package/lib/index.js +6897 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -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/chatcode-update.d.ts +47 -0
- package/lib/types/client/AccountMenu.d.ts +1 -0
- package/lib/types/client/AiRatioSection.d.ts +3 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/ChatcodeGate.d.ts +6 -0
- package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -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 +13 -0
- package/lib/types/client/FenceErrorNotice.d.ts +5 -0
- package/lib/types/client/FileTree.d.ts +41 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +12 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/ReleaseNotesDialog.d.ts +11 -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/Sidebar.d.ts +8 -0
- package/lib/types/client/TabBar.d.ts +54 -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 +27 -0
- package/lib/types/client/UpdateDialog.d.ts +10 -0
- package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
- package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +345 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -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/chat-presentation.d.ts +13 -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/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +66 -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 +44 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -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-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +571 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +21 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -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 +52 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -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 +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +445 -0
- package/lib/types/client/session-pins.d.ts +14 -0
- package/lib/types/client/settings-nav-icon.d.ts +27 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/split-pane.d.ts +38 -0
- package/lib/types/client/state.d.ts +480 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
- package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
- package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
- package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
- package/lib/types/client/ui-tweaks/index.d.ts +279 -0
- package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
- package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
- package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/useChatcodeAccount.d.ts +10 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/codingplan.d.ts +37 -0
- package/lib/types/config.d.ts +95 -0
- package/lib/types/context-types.d.ts +540 -0
- package/lib/types/fs-operations.d.ts +30 -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 +124 -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/login-route.d.ts +2 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +26 -0
- package/lib/types/prefs-shared.d.ts +257 -0
- package/lib/types/pty-deps.d.ts +79 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/ui-tweaks/archive.d.ts +71 -0
- package/lib/types/ui-tweaks/config.d.ts +186 -0
- package/lib/types/ui-tweaks/git-web.d.ts +43 -0
- package/lib/types/ui-tweaks/git.d.ts +311 -0
- package/lib/types/ui-tweaks/index.d.ts +17 -0
- package/lib/types/ui-tweaks/mcp.d.ts +103 -0
- package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
- package/lib/types/ui-tweaks/web.d.ts +55 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +213 -0
- package/scripts/install.ps1 +310 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/chatcode-update.ts +193 -0
- package/src/client/AccountMenu.tsx +177 -0
- package/src/client/AiRatioSection.module.css +329 -0
- package/src/client/AiRatioSection.tsx +292 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/ChatcodeGate.tsx +35 -0
- package/src/client/CodingPlanModelsSettings.module.css +118 -0
- package/src/client/CodingPlanModelsSettings.tsx +101 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +563 -0
- package/src/client/FenceErrorNotice.tsx +40 -0
- package/src/client/FileTree.tsx +701 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +722 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/ReleaseNotesDialog.tsx +91 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +932 -0
- package/src/client/SideCardSection.tsx +1169 -0
- package/src/client/Sidebar.tsx +1857 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +279 -0
- package/src/client/UpdateDialog.tsx +186 -0
- package/src/client/UpdateSettingsPanel.tsx +29 -0
- package/src/client/UpdateSettingsSection.tsx +22 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/UsageHeatmapSection.module.css +440 -0
- package/src/client/UsageHeatmapSection.tsx +301 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +433 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +275 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chat-presentation.ts +95 -0
- package/src/client/chunk-loader.ts +350 -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/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +501 -0
- package/src/client/intercept.tsx +172 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +37 -0
- package/src/client/layout.css +156 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +387 -0
- package/src/client/locales-de.ts +372 -0
- package/src/client/locales-fr.ts +379 -0
- package/src/client/locales-hi.ts +386 -0
- package/src/client/locales-id.ts +384 -0
- package/src/client/locales-it.ts +377 -0
- package/src/client/locales-ja.ts +486 -0
- package/src/client/locales-ko.ts +378 -0
- package/src/client/locales-nl.ts +384 -0
- package/src/client/locales-pl.ts +388 -0
- package/src/client/locales-pt.ts +369 -0
- package/src/client/locales-ru.ts +384 -0
- package/src/client/locales-sv.ts +369 -0
- package/src/client/locales-th.ts +386 -0
- package/src/client/locales-tr.ts +386 -0
- package/src/client/locales-vi.ts +386 -0
- package/src/client/locales-zh-HK.ts +401 -0
- package/src/client/locales-zh-MO.ts +401 -0
- package/src/client/locales-zh-TW.ts +401 -0
- package/src/client/locales.ts +1229 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +31 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -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 +79 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +184 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +153 -0
- package/src/client/plugins-viewers.ts +44 -0
- package/src/client/prefs.ts +216 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +873 -0
- package/src/client/session-pins.tsx +334 -0
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +3607 -0
- package/src/client/split-pane.tsx +333 -0
- package/src/client/state.ts +1646 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/ui-tweaks/appicons.ts +24 -0
- package/src/client/ui-tweaks/archive.tsx +281 -0
- package/src/client/ui-tweaks/branding.tsx +232 -0
- package/src/client/ui-tweaks/cachehit.tsx +122 -0
- package/src/client/ui-tweaks/gitbar.tsx +2559 -0
- package/src/client/ui-tweaks/index.tsx +1696 -0
- package/src/client/ui-tweaks/mcp.tsx +582 -0
- package/src/client/ui-tweaks/notifier.ts +514 -0
- package/src/client/ui-tweaks/whale.tsx +171 -0
- package/src/client/upload.ts +187 -0
- package/src/client/useChatcodeAccount.ts +103 -0
- package/src/client/wco.ts +120 -0
- package/src/codingplan.ts +146 -0
- package/src/config.ts +207 -0
- package/src/context-types.ts +559 -0
- package/src/fs-operations.ts +97 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1524 -0
- package/src/invariant.ts +32 -0
- package/src/login-route.ts +26 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +81 -0
- package/src/prefs-shared.ts +303 -0
- package/src/pty-deps.ts +241 -0
- package/src/pty-manager.ts +376 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/ui-tweaks/archive.ts +293 -0
- package/src/ui-tweaks/config.ts +257 -0
- package/src/ui-tweaks/git-web.ts +421 -0
- package/src/ui-tweaks/git.ts +1524 -0
- package/src/ui-tweaks/index.ts +61 -0
- package/src/ui-tweaks/mcp.ts +535 -0
- package/src/ui-tweaks/turn-outcome.ts +105 -0
- package/src/ui-tweaks/web.ts +225 -0
- package/src/wire.ts +110 -0
- package/vendor/addon-fit.js +2 -0
- package/vendor/xterm.css +218 -0
- package/vendor/xterm.js +5 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor tab's custom settings panel ("打开方式"): the file tree's
|
|
3
|
+
* "open with" configuration — the optional SSH host marking the workspace as
|
|
4
|
+
* remote, and the user-defined editors (name + URL template with `{path}` +
|
|
5
|
+
* whether they speak the VSCode URL dialect). Persisted as the editor
|
|
6
|
+
* blob's `openWith` key through the settings popup's `updatePluginSetting`.
|
|
7
|
+
*
|
|
8
|
+
* The popup renders the declarative rows (the editorExplorer picker) ABOVE
|
|
9
|
+
* this panel — SettingsBody renders the custom panel after the row list, so
|
|
10
|
+
* this component owns only its own section.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from 'react'
|
|
13
|
+
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
+
import {
|
|
15
|
+
isValidCustomEditor,
|
|
16
|
+
newCustomEditorId,
|
|
17
|
+
parseOpenWithConfig,
|
|
18
|
+
type CustomEditor,
|
|
19
|
+
type OpenWithConfig,
|
|
20
|
+
} from './open-with.ts'
|
|
21
|
+
import { t } from './locales.ts'
|
|
22
|
+
import css from './SideCardSection.module.css'
|
|
23
|
+
|
|
24
|
+
export function OpenWithSettings(props: {
|
|
25
|
+
pluginSettings: Record<string, unknown>
|
|
26
|
+
updatePluginSetting: (key: string, value: unknown) => void
|
|
27
|
+
}) {
|
|
28
|
+
const { pluginSettings, updatePluginSetting } = props
|
|
29
|
+
// Local draft seeded from the persisted blob (each commit re-renders the
|
|
30
|
+
// popup with the round-tripped prefs; the local state stays authoritative
|
|
31
|
+
// while the popup is open, so typing never fights an in-flight write).
|
|
32
|
+
const [draft, setDraft] = useState<OpenWithConfig>(() => parseOpenWithConfig(pluginSettings.openWith))
|
|
33
|
+
|
|
34
|
+
const commit = (next: OpenWithConfig): void => {
|
|
35
|
+
setDraft(next)
|
|
36
|
+
updatePluginSetting('openWith', next)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const setSshHost = (sshHost: string): void => commit({ ...draft, sshHost })
|
|
40
|
+
|
|
41
|
+
const patchCustom = (id: string, patch: Partial<CustomEditor>): void => {
|
|
42
|
+
commit({
|
|
43
|
+
...draft,
|
|
44
|
+
customEditors: draft.customEditors.map(editor => editor.id === id ? { ...editor, ...patch } : editor),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const removeCustom = (id: string): void => {
|
|
49
|
+
commit({
|
|
50
|
+
...draft,
|
|
51
|
+
customEditors: draft.customEditors.filter(editor => editor.id !== id),
|
|
52
|
+
// A removed editor can never stay pinned (the menu prunes unknown ids
|
|
53
|
+
// anyway; this keeps the blob clean).
|
|
54
|
+
pinned: draft.pinned.filter(pinnedId => pinnedId !== `custom:${id}`),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const addCustom = (): void => {
|
|
59
|
+
commit({
|
|
60
|
+
...draft,
|
|
61
|
+
customEditors: [...draft.customEditors, { id: newCustomEditorId(), name: '', urlTemplate: '', isVscodeFamily: false }],
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hasInvalid = draft.customEditors.some(editor => !isValidCustomEditor(editor))
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={css.popupRows}>
|
|
69
|
+
<div className={css.popupRow}>
|
|
70
|
+
<span className={css.rowText}>
|
|
71
|
+
<span className={css.title}>{t('openWithSettingsSshTitle')}</span>
|
|
72
|
+
<span className={css.desc}>{t('openWithSettingsSshDesc')}</span>
|
|
73
|
+
</span>
|
|
74
|
+
<input
|
|
75
|
+
className={css.typedInput}
|
|
76
|
+
value={draft.sshHost}
|
|
77
|
+
placeholder={t('openWithSettingsSshPlaceholder')}
|
|
78
|
+
spellCheck={false}
|
|
79
|
+
onChange={(event) => { setSshHost(event.target.value) }}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div className={css.popupRow}>
|
|
83
|
+
<span className={css.rowText}>
|
|
84
|
+
<span className={css.title}>{t('openWithSettingsCustomTitle')}</span>
|
|
85
|
+
<span className={css.desc}>{t('openWithSettingsCustomDesc')}</span>
|
|
86
|
+
</span>
|
|
87
|
+
<span className={css.control}>
|
|
88
|
+
<button type="button" className={css.done} onClick={addCustom}>
|
|
89
|
+
{t('openWithSettingsAdd')}
|
|
90
|
+
</button>
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
{draft.customEditors.map(editor => (
|
|
94
|
+
<div key={editor.id} className={css.openWithEditorRow}>
|
|
95
|
+
<input
|
|
96
|
+
className={css.openWithEditorInput}
|
|
97
|
+
value={editor.name}
|
|
98
|
+
placeholder={t('openWithSettingsName')}
|
|
99
|
+
spellCheck={false}
|
|
100
|
+
onChange={(event) => { patchCustom(editor.id, { name: event.target.value }) }}
|
|
101
|
+
/>
|
|
102
|
+
<input
|
|
103
|
+
className={css.openWithEditorTemplate}
|
|
104
|
+
value={editor.urlTemplate}
|
|
105
|
+
placeholder={t('openWithSettingsTemplate')}
|
|
106
|
+
spellCheck={false}
|
|
107
|
+
onChange={(event) => { patchCustom(editor.id, { urlTemplate: event.target.value }) }}
|
|
108
|
+
/>
|
|
109
|
+
<label className={css.openWithFamily} title={t('openWithSettingsFamilyDesc')}>
|
|
110
|
+
<input
|
|
111
|
+
type="checkbox"
|
|
112
|
+
checked={editor.isVscodeFamily}
|
|
113
|
+
onChange={(event) => { patchCustom(editor.id, { isVscodeFamily: event.currentTarget.checked }) }}
|
|
114
|
+
/>
|
|
115
|
+
<span>{t('openWithSettingsFamily')}</span>
|
|
116
|
+
</label>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className={css.openWithRemove}
|
|
120
|
+
aria-label={t('openWithSettingsRemove')}
|
|
121
|
+
title={t('openWithSettingsRemove')}
|
|
122
|
+
onClick={() => { removeCustom(editor.id) }}
|
|
123
|
+
>
|
|
124
|
+
<IconCloseOutline16 size={14} />
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
{hasInvalid && (
|
|
129
|
+
<div className={css.openWithHint} role="note">
|
|
130
|
+
{t('openWithSettingsInvalidHint')}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file tree's "open with" vocabulary: which external editors / file
|
|
3
|
+
* managers the context menu can hand a row's path to, how the per-user
|
|
4
|
+
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
|
|
5
|
+
* persisted blob, and how the external URL is built (local `file` URLs and
|
|
6
|
+
* VSCode-family SSH-remote URLs).
|
|
7
|
+
*
|
|
8
|
+
* Pure by design (no React / no `api`), so the whole surface is unit-testable
|
|
9
|
+
* without a DOM or a host route. The menu and the settings panel only consume
|
|
10
|
+
* the exported types and functions.
|
|
11
|
+
*/
|
|
12
|
+
import type { CopyKey } from './locales.ts'
|
|
13
|
+
|
|
14
|
+
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
|
|
15
|
+
export interface CustomEditor {
|
|
16
|
+
id: string
|
|
17
|
+
/** Human label shown in the menu (e.g. "Windsurf"). */
|
|
18
|
+
name: string
|
|
19
|
+
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
|
|
20
|
+
urlTemplate: string
|
|
21
|
+
/** Whether the editor speaks the VSCode URL dialect — the only editors
|
|
22
|
+
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
|
|
23
|
+
isVscodeFamily: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The per-user "open with" configuration (one key of the editor blob). */
|
|
27
|
+
export interface OpenWithConfig {
|
|
28
|
+
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
|
|
29
|
+
sshHost: string
|
|
30
|
+
/** User-defined editors, appended after the built-ins. */
|
|
31
|
+
customEditors: CustomEditor[]
|
|
32
|
+
/** Ids of open-with targets pinned to the menu's top level
|
|
33
|
+
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
|
|
34
|
+
pinned: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One menu-visible open target (built-in or custom, SSH-filtered). */
|
|
38
|
+
export interface OpenWithTarget {
|
|
39
|
+
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
|
|
40
|
+
id: string
|
|
41
|
+
/** Locale key of a built-in label (custom editors carry `name` instead). */
|
|
42
|
+
nameKey?: CopyKey
|
|
43
|
+
/** User-defined label (custom editors only; '' for built-ins). */
|
|
44
|
+
name: string
|
|
45
|
+
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
|
|
46
|
+
kind: 'reveal' | 'url'
|
|
47
|
+
/** URL template with `{path}`; undefined for reveal targets. */
|
|
48
|
+
urlTemplate?: string
|
|
49
|
+
/** Whether the editor talks the VSCode URL dialect. */
|
|
50
|
+
isVscodeFamily: boolean
|
|
51
|
+
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
|
|
52
|
+
localOnly: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The default open-with configuration (fresh documents). */
|
|
56
|
+
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
|
|
57
|
+
sshHost: '',
|
|
58
|
+
customEditors: [],
|
|
59
|
+
pinned: [],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The built-in open targets, in menu order. */
|
|
63
|
+
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
|
|
64
|
+
{
|
|
65
|
+
id: 'explorer',
|
|
66
|
+
nameKey: 'openWithExplorer',
|
|
67
|
+
name: '',
|
|
68
|
+
kind: 'reveal',
|
|
69
|
+
isVscodeFamily: false,
|
|
70
|
+
localOnly: true,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'vscode',
|
|
74
|
+
nameKey: 'openWithVscode',
|
|
75
|
+
name: '',
|
|
76
|
+
kind: 'url',
|
|
77
|
+
urlTemplate: 'vscode://file/{path}',
|
|
78
|
+
isVscodeFamily: true,
|
|
79
|
+
localOnly: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'cursor',
|
|
83
|
+
nameKey: 'openWithCursor',
|
|
84
|
+
name: '',
|
|
85
|
+
kind: 'url',
|
|
86
|
+
urlTemplate: 'cursor://file/{path}',
|
|
87
|
+
isVscodeFamily: true,
|
|
88
|
+
localOnly: false,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'zed',
|
|
92
|
+
nameKey: 'openWithZed',
|
|
93
|
+
name: '',
|
|
94
|
+
kind: 'url',
|
|
95
|
+
urlTemplate: 'zed://file/{path}',
|
|
96
|
+
isVscodeFamily: false,
|
|
97
|
+
localOnly: true,
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/** Whether a persisted value makes a structurally valid custom-editor row.
|
|
102
|
+
* Name/template may be empty — the settings panel edits rows in place and
|
|
103
|
+
* an in-progress row must survive the round-trip; the MENU hides rows that
|
|
104
|
+
* fail the stricter {@link isValidCustomEditor} check. */
|
|
105
|
+
function isCustomEditor(value: unknown): value is CustomEditor {
|
|
106
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
107
|
+
const record = value as Record<string, unknown>
|
|
108
|
+
return typeof record.id === 'string' && record.id !== ''
|
|
109
|
+
&& typeof record.name === 'string'
|
|
110
|
+
&& typeof record.urlTemplate === 'string'
|
|
111
|
+
&& typeof record.isVscodeFamily === 'boolean'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
|
|
116
|
+
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
|
|
117
|
+
* are kept verbatim (unknown ids are pruned when the targets are resolved —
|
|
118
|
+
* the menu is the only consumer of the resolved list).
|
|
119
|
+
*/
|
|
120
|
+
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
|
|
121
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
|
|
122
|
+
const record = raw as Record<string, unknown>
|
|
123
|
+
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
|
|
124
|
+
const customEditors = Array.isArray(record.customEditors)
|
|
125
|
+
? record.customEditors.filter(isCustomEditor)
|
|
126
|
+
: []
|
|
127
|
+
const pinned = Array.isArray(record.pinned)
|
|
128
|
+
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
|
|
129
|
+
: []
|
|
130
|
+
return { sshHost, customEditors, pinned }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a custom editor id belongs to this config (id prefix match). */
|
|
134
|
+
function customIdOf(id: string): string {
|
|
135
|
+
return `custom:${id}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The menu-visible open targets, in order (built-ins then custom editors).
|
|
140
|
+
* In SSH mode the local-only targets (the OS file manager, Zed, custom
|
|
141
|
+
* editors without the VSCode dialect) are dropped — they cannot reach a
|
|
142
|
+
* remote path. Unknown pinned ids are pruned here too.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
|
|
145
|
+
const ssh = config.sshHost.trim() !== ''
|
|
146
|
+
const targets: OpenWithTarget[] = [
|
|
147
|
+
...OPEN_WITH_BUILTINS,
|
|
148
|
+
...config.customEditors
|
|
149
|
+
.filter(isValidCustomEditor)
|
|
150
|
+
.map((editor): OpenWithTarget => ({
|
|
151
|
+
id: customIdOf(editor.id),
|
|
152
|
+
name: editor.name,
|
|
153
|
+
kind: 'url',
|
|
154
|
+
urlTemplate: editor.urlTemplate,
|
|
155
|
+
isVscodeFamily: editor.isVscodeFamily,
|
|
156
|
+
localOnly: !editor.isVscodeFamily,
|
|
157
|
+
})),
|
|
158
|
+
]
|
|
159
|
+
return targets.filter(target => !(ssh && target.localOnly))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The SSH hint appended to a target's label in remote mode. */
|
|
163
|
+
export function openWithSshActive(config: OpenWithConfig): boolean {
|
|
164
|
+
return config.sshHost.trim() !== ''
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The URL to open for one resolved target, or undefined when the target has
|
|
169
|
+
* no URL form (reveal) or the template is malformed. The path is inserted
|
|
170
|
+
* RAW into the template (browsers percent-encode as needed; VSCode-family
|
|
171
|
+
* URL parsers consume the absolute path with its leading slash, e.g.
|
|
172
|
+
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
|
|
173
|
+
*/
|
|
174
|
+
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
|
|
175
|
+
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
|
|
176
|
+
const normalized = normalizeUrlPath(path)
|
|
177
|
+
const ssh = openWithSshActive(config)
|
|
178
|
+
if (ssh && target.isVscodeFamily) {
|
|
179
|
+
const scheme = schemeOf(target.urlTemplate)
|
|
180
|
+
if (scheme === undefined) return undefined
|
|
181
|
+
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
|
|
182
|
+
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
|
|
183
|
+
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
|
|
184
|
+
}
|
|
185
|
+
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
|
|
186
|
+
return target.urlTemplate.replace('{path}', normalized)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Whether a template starts with a `scheme://` prefix (the only shape the
|
|
190
|
+
* host's external opener accepts and the settings panel suggests). */
|
|
191
|
+
function hasUrlScheme(template: string): boolean {
|
|
192
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The scheme of a URL template (the part before the first ':'), or undefined. */
|
|
196
|
+
function schemeOf(template: string): string | undefined {
|
|
197
|
+
const at = template.indexOf(':')
|
|
198
|
+
if (at <= 0) return undefined
|
|
199
|
+
const scheme = template.slice(0, at)
|
|
200
|
+
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
|
|
204
|
+
export function normalizeUrlPath(path: string): string {
|
|
205
|
+
return path.replace(/\\/g, '/')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A fresh custom-editor id (uuid when available, time-based fallback). */
|
|
209
|
+
export function newCustomEditorId(): string {
|
|
210
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
211
|
+
return crypto.randomUUID()
|
|
212
|
+
}
|
|
213
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Validate one custom-editor row before the settings panel accepts it. */
|
|
217
|
+
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
|
|
218
|
+
return row.name.trim() !== ''
|
|
219
|
+
&& row.urlTemplate.includes('{path}')
|
|
220
|
+
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
|
|
221
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interception of the chat's file-open funnel. The client runtime's
|
|
3
|
+
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
|
|
4
|
+
* through — ui-conversation's apply.ts resolves the path against the session
|
|
5
|
+
* cwd and calls it for tool-row path links, the produced-files row, and
|
|
6
|
+
* prose file mentions alike (verified against the DSH source:
|
|
7
|
+
* `packages/client/ui-conversation/src/client/apply.ts` is the only
|
|
8
|
+
* production caller). Wrapping that one method reroutes those opens into the
|
|
9
|
+
* sidebar editor instead of the Host OS — no DSH modification needed.
|
|
10
|
+
*
|
|
11
|
+
* The wrapper is dependency-free by design (no React / ui-primitives), so
|
|
12
|
+
* the takeover logic is unit-testable and the file stays importable from the
|
|
13
|
+
* test runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
|
|
17
|
+
export interface OpenPathService {
|
|
18
|
+
openPath(path: string): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
|
|
22
|
+
export interface OpenPathInterceptDeps {
|
|
23
|
+
/**
|
|
24
|
+
* Whether to take over this call: the `interceptOpenPath` pref AND the
|
|
25
|
+
* editor tab's own enable switch must both be on (an editor that cannot
|
|
26
|
+
* open must not swallow opens — they fall through to the Host).
|
|
27
|
+
*/
|
|
28
|
+
takeoverEnabled(): boolean
|
|
29
|
+
/** The session whose scope the sidebar editor loads the file in (current session). */
|
|
30
|
+
currentSessionId(): string | undefined
|
|
31
|
+
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
|
32
|
+
openInSidebar(path: string, sessionId: string): void
|
|
33
|
+
/** Route a folder-reveal gesture ("Show in folder" passes '.') into the sidebar explorer. */
|
|
34
|
+
revealInExplorer(path: string, sessionId: string): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a path is the "Show in folder" folder-reveal gesture. The stock
|
|
39
|
+
* ui-deliverables row passes `'.'` (the session workspace root, resolved by
|
|
40
|
+
* the chat view to `"<cwd>/."`); any path whose final segment is `.` is the
|
|
41
|
+
* same gesture. A directory has no editor content, so these opens must reach
|
|
42
|
+
* the explorer instead of an editor tab.
|
|
43
|
+
*/
|
|
44
|
+
export function isFolderRevealPath(path: string): boolean {
|
|
45
|
+
if (path === '.' || path === './') return true
|
|
46
|
+
const trimmed = path.replace(/[\\/]+$/, '')
|
|
47
|
+
return trimmed === '.' || /[\\/]\.$/.test(trimmed)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
|
|
52
|
+
* editor instead of the Host OS and resolve as success (the original's
|
|
53
|
+
* callers ignore the result); anything that declines falls through to the
|
|
54
|
+
* original method untouched. The one exception is the folder-reveal gesture,
|
|
55
|
+
* which is routed to {@link OpenPathInterceptDeps.revealInExplorer} instead.
|
|
56
|
+
* @param workspaces - the client workspaces service to wrap.
|
|
57
|
+
* @param deps - per-call takeover decisions.
|
|
58
|
+
* @returns the disposer restoring the original method (HMR-safe).
|
|
59
|
+
*/
|
|
60
|
+
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
|
|
61
|
+
// The RAW method reference (never a bound copy): restore must put back the
|
|
62
|
+
// exact original so a chain of wrappers (other plugins wrapping the same
|
|
63
|
+
// method) keeps working across disposals in any order.
|
|
64
|
+
const original = workspaces.openPath
|
|
65
|
+
workspaces.openPath = (path: string): Promise<void> => {
|
|
66
|
+
if (deps.takeoverEnabled()) {
|
|
67
|
+
const sessionId = deps.currentSessionId()
|
|
68
|
+
if (sessionId !== undefined) {
|
|
69
|
+
if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId)
|
|
70
|
+
else deps.openInSidebar(path, sessionId)
|
|
71
|
+
return Promise.resolve()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return original.call(workspaces, path)
|
|
75
|
+
}
|
|
76
|
+
return () => {
|
|
77
|
+
workspaces.openPath = original
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path projection helpers shared by the explorer rows: a path relative to
|
|
3
|
+
* the session cwd (for the @-reference button and "copy relative path").
|
|
4
|
+
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
|
5
|
+
* to '/' before comparison.
|
|
6
|
+
*
|
|
7
|
+
* This module is dependency-free (no node:path in the client bundle): the
|
|
8
|
+
* host is the authority for path semantics, so this mirror deliberately
|
|
9
|
+
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
|
|
10
|
+
* (drive letters, UNC) plus POSIX roots. A form the host would reject
|
|
11
|
+
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
|
|
12
|
+
* fails loudly in the host's requireAbsolute instead of being silently
|
|
13
|
+
* joined onto the cwd.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
|
|
18
|
+
* POSIX roots, Windows drive letters, and Windows UNC network shares in
|
|
19
|
+
* both backslash (`\\server\share\...`) and forward-slash
|
|
20
|
+
* (`//server/share/...`) form. Deliberately a superset — see the module
|
|
21
|
+
* comment — so a produced UNC path is never joined onto the cwd.
|
|
22
|
+
*/
|
|
23
|
+
export function isAbsolutePath(path: string): boolean {
|
|
24
|
+
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The path relative to the session's working directory.
|
|
29
|
+
* @param cwd - the explorer root (absolute).
|
|
30
|
+
* @param path - an absolute entry path from the fs-tree.
|
|
31
|
+
* @returns the relative path with '/' separators ('.' for the cwd itself),
|
|
32
|
+
* or `path` unchanged when it lies outside the cwd.
|
|
33
|
+
*
|
|
34
|
+
* The prefix test is case-insensitive: Windows paths (and macOS's
|
|
35
|
+
* case-insensitive volumes) may arrive with different casing than the cwd
|
|
36
|
+
* row, and the containment decision must not depend on it. The returned
|
|
37
|
+
* relative text keeps the caller's own casing.
|
|
38
|
+
*/
|
|
39
|
+
export function relativeTo(cwd: string, path: string): string {
|
|
40
|
+
const base = cwd.replace(/[\\/]+$/, '')
|
|
41
|
+
const norm = (value: string): string => value.replace(/\\/g, '/')
|
|
42
|
+
const nBase = norm(base)
|
|
43
|
+
const nPath = norm(path)
|
|
44
|
+
if (nPath === nBase) return '.'
|
|
45
|
+
if (nPath.toLowerCase().startsWith(`${nBase.toLowerCase()}/`)) return nPath.slice(nBase.length + 1)
|
|
46
|
+
return path
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
|
51
|
+
* style and — on Windows-style drive paths — of letter case. A client-side
|
|
52
|
+
* mirror of the host's `isWithin` (fs-tree.ts) used to decide whether a
|
|
53
|
+
* git-derived path can be opened in the editor (a linked worktree outside
|
|
54
|
+
* the session workspace cannot: the host's workspace fence would reject it).
|
|
55
|
+
*/
|
|
56
|
+
export function isWithinWorkspace(base: string, target: string): boolean {
|
|
57
|
+
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
|
|
58
|
+
const b = norm(base)
|
|
59
|
+
const t = norm(target)
|
|
60
|
+
const lb = b.toLowerCase()
|
|
61
|
+
const lt = t.toLowerCase()
|
|
62
|
+
return lt === lb || lt.startsWith(`${lb}/`)
|
|
63
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session pinned-terminal resolution (v0.17.0+).
|
|
3
|
+
*
|
|
4
|
+
* A pinned terminal tab lives in its HOME session's state (the only
|
|
5
|
+
* authoritative copy) — switching sessions never copies or projects it.
|
|
6
|
+
* The viewer session's TabBar renders the tabs OTHER sessions have pinned
|
|
7
|
+
* as VIRTUAL tabs appended to the first leaf's tab list, so the user sees
|
|
8
|
+
* them inline with their own tabs. Clicking a virtual tab activates it
|
|
9
|
+
* in-place: TerminalView connects to the home session's PTY via WebSocket
|
|
10
|
+
* (sessionId + tab query params resolve to the home PTY on the host side),
|
|
11
|
+
* so the terminal renders in the current workbench without jumping sessions.
|
|
12
|
+
*
|
|
13
|
+
* Visibility rule:
|
|
14
|
+
*
|
|
15
|
+
* | pin.scope | visible when |
|
|
16
|
+
* |-----------|--------------|
|
|
17
|
+
* | `global` | any session (cwd-independent) |
|
|
18
|
+
* | `workspace` | `viewer.cwd === tab.pin.homeCwd` (both undefined match; viewer.cwd unknown → conservative visible) |
|
|
19
|
+
*
|
|
20
|
+
* The "viewer.cwd unknown → visible" branch is intentional: during
|
|
21
|
+
* hydration the session summary may carry no cwd yet, and hiding pinned
|
|
22
|
+
* workspace tabs on first paint would flash them away. Once the cwd
|
|
23
|
+
* resolves, the next store notify re-runs the resolver with the real cwd.
|
|
24
|
+
*
|
|
25
|
+
* The viewer's OWN session is excluded: its pinned tabs are already on its
|
|
26
|
+
* own tab strip, so rendering them again as virtual tabs would double-show.
|
|
27
|
+
* Tabs whose `pin` field is missing or whose `type` is not `'terminal'` are
|
|
28
|
+
* ignored — only terminal tabs can be pinned.
|
|
29
|
+
*/
|
|
30
|
+
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
|
31
|
+
|
|
32
|
+
/** A pinned terminal surfaced to the viewer, paired with its home session. */
|
|
33
|
+
export interface PinnedTabEntry {
|
|
34
|
+
tab: SidebarTab
|
|
35
|
+
homeSessionId: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A viewer's session identity for visibility resolution. */
|
|
39
|
+
export interface PinnedViewer {
|
|
40
|
+
sessionId: string
|
|
41
|
+
cwd: string | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The home-session scope stored on a pinned virtual tab's meta, so
|
|
45
|
+
* TerminalView connects to the home session's PTY (not the viewer's). */
|
|
46
|
+
export interface PinnedHomeScope {
|
|
47
|
+
sessionId: string
|
|
48
|
+
cwd: string | undefined
|
|
49
|
+
/** The original tab id in the home session (TerminalView's `tab` param). */
|
|
50
|
+
tabId: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PINNED_META_KEY = '__pinnedHome'
|
|
54
|
+
const PINNED_VID_PREFIX = 'pinned:'
|
|
55
|
+
|
|
56
|
+
/** Whether a tab id is a pinned virtual id (prefixed). */
|
|
57
|
+
export function isPinnedVirtualId(tabId: string): boolean {
|
|
58
|
+
return tabId.startsWith(PINNED_VID_PREFIX)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a pinned virtual id into its home session id and original tab id.
|
|
62
|
+
* Format: `pinned:<homeSessionId>:<originalTabId>` — session ids are UUIDs
|
|
63
|
+
* (no colons), so the first colon after the prefix delimits the session. */
|
|
64
|
+
export function parsePinnedVirtualId(tabId: string): { homeSessionId: string; tabId: string } {
|
|
65
|
+
const rest = tabId.slice(PINNED_VID_PREFIX.length)
|
|
66
|
+
const sep = rest.indexOf(':')
|
|
67
|
+
if (sep < 0) return { homeSessionId: rest, tabId: '' }
|
|
68
|
+
return { homeSessionId: rest.slice(0, sep), tabId: rest.slice(sep + 1) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Extract the home scope from a pinned virtual tab's meta (undefined for
|
|
72
|
+
* regular tabs). */
|
|
73
|
+
export function getPinnedHomeScope(tab: SidebarTab): PinnedHomeScope | undefined {
|
|
74
|
+
const meta = tab.meta as Record<string, unknown> | undefined
|
|
75
|
+
return (meta?.[PINNED_META_KEY] as PinnedHomeScope | undefined) ?? undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a tab is a pinned virtual tab (injected from another session). */
|
|
79
|
+
export function isPinnedVirtualTab(tab: SidebarTab): boolean {
|
|
80
|
+
return getPinnedHomeScope(tab) !== undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Create a virtual SidebarTab for a pinned entry. The virtual id is unique
|
|
84
|
+
* (prefixed with home session) to avoid collision with the viewer's own
|
|
85
|
+
* tab ids; the original id is stored in meta for TerminalView. */
|
|
86
|
+
export function createPinnedVirtualTab(entry: PinnedTabEntry): SidebarTab {
|
|
87
|
+
const { tab, homeSessionId } = entry
|
|
88
|
+
const home: PinnedHomeScope = {
|
|
89
|
+
sessionId: homeSessionId,
|
|
90
|
+
cwd: tab.pin?.homeCwd,
|
|
91
|
+
tabId: tab.id,
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...tab,
|
|
95
|
+
id: PINNED_VID_PREFIX + homeSessionId + ':' + tab.id,
|
|
96
|
+
meta: { ...(tab.meta as Record<string, unknown> | undefined ?? {}), [PINNED_META_KEY]: home },
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Inject pinned virtual tabs into the first leaf of a split tree, and
|
|
101
|
+
* override that leaf's `active` when a pinned tab is activated. Returns
|
|
102
|
+
* the original tree when there are no pinned tabs and no active override. */
|
|
103
|
+
export function injectPinnedIntoTree(
|
|
104
|
+
tree: SplitNode,
|
|
105
|
+
pinned: readonly SidebarTab[],
|
|
106
|
+
activePinnedId: string | null,
|
|
107
|
+
): SplitNode {
|
|
108
|
+
if (pinned.length === 0 && activePinnedId === null) return tree
|
|
109
|
+
if (tree.kind === 'leaf') {
|
|
110
|
+
return {
|
|
111
|
+
...tree,
|
|
112
|
+
tabs: pinned.length > 0 ? [...tree.tabs, ...pinned] : tree.tabs,
|
|
113
|
+
active: activePinnedId ?? tree.active,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
...tree,
|
|
118
|
+
children: [
|
|
119
|
+
injectPinnedIntoTree(tree.children[0]!, pinned, activePinnedId),
|
|
120
|
+
...tree.children.slice(1),
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a pinned tab is visible to the viewer session. Conservative on
|
|
127
|
+
* unknown cwd: a `workspace` pin with no `homeCwd` is visible everywhere
|
|
128
|
+
* (the pin was set before the home session's cwd resolved), and a viewer
|
|
129
|
+
* whose cwd is unknown sees every workspace pin (avoids hydration flash).
|
|
130
|
+
*/
|
|
131
|
+
export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean {
|
|
132
|
+
const pin = tab.pin
|
|
133
|
+
if (pin === undefined) return false
|
|
134
|
+
if (pin.scope === 'global') return true
|
|
135
|
+
// workspace scope
|
|
136
|
+
const home = pin.homeCwd
|
|
137
|
+
if (home === undefined) return true
|
|
138
|
+
if (viewer.cwd === undefined) return true
|
|
139
|
+
return viewer.cwd === home
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Collect every pinned terminal visible to the viewer across ALL cached
|
|
144
|
+
* session states. Excludes the viewer's own session (those tabs are on its
|
|
145
|
+
* own strip). Order is stable: sessions in the cache's insertion order,
|
|
146
|
+
* tabs in tree order (splits → bottomSplits → floats) within each session
|
|
147
|
+
* — the order tabs were opened/pinned, so the rail never reorders between
|
|
148
|
+
* renders.
|
|
149
|
+
*/
|
|
150
|
+
export function collectPinnedTabs(
|
|
151
|
+
bySession: ReadonlyMap<string, SidebarState>,
|
|
152
|
+
viewer: PinnedViewer,
|
|
153
|
+
): PinnedTabEntry[] {
|
|
154
|
+
const entries: PinnedTabEntry[] = []
|
|
155
|
+
for (const [homeSessionId, state] of bySession) {
|
|
156
|
+
if (homeSessionId === viewer.sessionId) continue
|
|
157
|
+
collectFromTree(state.splits, homeSessionId, viewer, entries)
|
|
158
|
+
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
|
|
159
|
+
for (const float of state.floats) {
|
|
160
|
+
if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) {
|
|
161
|
+
entries.push({ tab: float.tab, homeSessionId })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return entries
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Walk one split tree depth-first, collecting visible pinned terminals. */
|
|
169
|
+
function collectFromTree(
|
|
170
|
+
node: SidebarState['splits'],
|
|
171
|
+
homeSessionId: string,
|
|
172
|
+
viewer: PinnedViewer,
|
|
173
|
+
out: PinnedTabEntry[],
|
|
174
|
+
): void {
|
|
175
|
+
if (node.kind === 'leaf') {
|
|
176
|
+
for (const tab of node.tabs) {
|
|
177
|
+
if (tab.type === 'terminal' && pinnedVisibleTo(tab, viewer)) {
|
|
178
|
+
out.push({ tab, homeSessionId })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
for (const child of node.children) collectFromTree(child, homeSessionId, viewer, out)
|
|
184
|
+
}
|