feat(editor): 文件树「在应用中打开」子菜单(外部编辑器/图钉固定/SSH 远端/自定义编辑器) (#334)
* feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors) The file tree right-click menu gains an 'Open with >' submenu with the resource manager (reveal in file manager), VS Code, Cursor and Zed, plus user-defined custom editors (name + URL template + VSCode-family flag). Each submenu row carries a pushpin that promotes the target to a direct top-level menu item. A configurable SSH host (settings: Files card gear) switches VSCode-family openers to <scheme>://vscode-remote/ssh-remote+<host>/<path> and hides host-local targets (file manager / Zed / non-VSCode custom editors). External opens run through the new fenced /sidebar/api/open.external route which spawns the platform opener with an argv array (no shell); the client never navigates raw scheme URLs itself. The settings seam's render panel now appends after the declarative rows instead of replacing them. * feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment - Submenu rows: the custom label is now a block-level flex row filling the primitives itemLabel, so the pushpin docks at the row's far right (matching the trailing check of every other menu); menu row icons normalized to 16px (DSH native menus use 16, ours were 14). - The submenu parent row gains a right-aligned chevron (the primitives Menu renders no arrow for submenu parents). - Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing the </> file mark), VscLinkExternal for the open-with parent and the browser tab's open-external button (globally), SiCursor/SiZedindustries brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0 (CC0) as IconVscode16 since later simple-icons releases dropped it. All monochrome currentColor to follow the active skin. - tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias — the package's exports map lists require before import, so the shared conditionNames picked the unshakeable CJS entry and bloated the core bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB.
This commit is contained in:
@@ -176,7 +176,7 @@ interface TabDescriptor {
|
||||
* 顺序提交)。
|
||||
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
|
||||
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
|
||||
* 与 `render`(自定义设置面板,替代行列表)。
|
||||
* 与 `render`(自定义设置面板,追加渲染在行列表之后,与行列表共存)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
@@ -223,7 +223,8 @@ interface TabDescriptor {
|
||||
}[]
|
||||
multi?: boolean
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
|
||||
/** 自定义设置面板(v0.12.0+):给出时**追加**渲染在行列表(toggles /
|
||||
* pluginToggles)之后,与行列表共存;未声明行列表时单独渲染。
|
||||
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
|
||||
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
|
||||
render?: (props: {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# 文件树「打开方式」子菜单设计(2026-08-22)
|
||||
|
||||
> 状态:已实现(分支 `feat/open-with-menu`)。本文档记录功能设计、关键取舍与实施偏差。
|
||||
|
||||
## 目标
|
||||
|
||||
文件树(`FileTree`)右键菜单新增「在应用中打开 >」子菜单:
|
||||
|
||||
- 内置 **资源管理器(在文件管理器中显示)、VS Code、Cursor、Zed** 四个打开方式;每个子菜单行右侧有**图钉**,点击可把该方式固定为右键菜单的**顶层直达项**(再点取消固定)。
|
||||
- **远端 SSH**:设置中配置可选 SSH host(`user@host` 或 `~/.ssh/config` 别名)。非空即整个工作区视为远端:VSCode 系条目(VS Code / Cursor / 自定义且勾选"VSCode 系")改用 `<scheme>://vscode-remote/ssh-remote+<host>/<path>` 打开;本地专用条目(资源管理器 / Zed / 非 VSCode 系自定义)从菜单隐藏。
|
||||
- **自定义编辑器**:名称 + URL 模板(`{path}` 占位符,如 `cursor://file/{path}`)+ 「是否 VSCode 系」开关;配置入口在**侧边栏设置 Files 卡片齿轮**(与既有 `editorExplorer` 下拉同一弹窗)。
|
||||
- 打开动作经宿主新路由 `POST /sidebar/api/open.external` 执行(argv 数组 spawn,无 shell 注入),浏览器模式与 DSH Desktop 行为一致;**不经过** `ctx.workspaces.openPath`(该入口已被 `wrapOpenPath` 劫持到侧边栏编辑器,见 `src/client/openpath-intercept.ts`)。
|
||||
|
||||
## 调研结论(reuse gate)
|
||||
|
||||
- DSH checkout(`~/.dsh/source/current`)无"文件树 open-with"、无 SSH 远程会话、无 `vscode://` 开启器;`host.openPath` 只能"默认应用打开",无法指定编辑器、无 reveal/select 语义 → 需要新增。
|
||||
- dsh-external hub 无同类成品;`dsh-open-in-vscode` 是工作区行菜单、依赖外部 slot,不适用。
|
||||
- primitives `Menu` 原生支持 `submenu?: readonly MenuItem[]`(子项点击走 `onSelect(child.id)`),`label: ReactNode` 可内嵌图钉;无现成图钉/品牌图标 → 引入 `react-icons`(`vsc` codicons 图钉 `VscPin`/`VscPinned` 与文件树 `VscFile`/`VscFolder(Opened)`、`si` 品牌剪影 Cursor/Zed、菜单父行 `VscLinkExternal`),VS Code 品牌剪影因 simple-icons 后期版本受微软商标政策下架而内嵌自 simple-icons@11.0.0(CC0,`IconVscode16`);菜单行图标统一 16px 与 DSH 原生菜单一致,子菜单父行的 `>` chevron 由 label 内右对齐元素提供(基元不渲染)。⚠️ react-icons 的 exports 把 `require` 排在 `import` 前,tsdown 共享 conditionNames 会选中不可树摇的 CJS 入口(bundle +6.4MB)——`tsdown.config.ts` 用 `resolve.alias` 钉死 `si`/`vsc` 的 ESM 入口,树摇后仅 +12KB。
|
||||
|
||||
## 数据模型
|
||||
|
||||
持久化在 `pluginSettings['editor'].openWith`(宿主 schema 的开放 map,无需改 PrefsSchema):
|
||||
|
||||
```json
|
||||
{
|
||||
"openWith": {
|
||||
"sshHost": "",
|
||||
"customEditors": [
|
||||
{ "id": "custom:<uuid>", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false }
|
||||
],
|
||||
"pinned": ["vscode", "custom:<uuid>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `parseOpenWithConfig`(`src/client/open-with.ts`)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;**结构合法但未填完的行(空 name/模板)保留**——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的 `isValidCustomEditor` 过滤。
|
||||
- 目标 id:内置 `explorer` / `vscode` / `cursor` / `zed`;自定义 `custom:<uuid>`。
|
||||
- URL 构建:
|
||||
- 本地:模板原文替换 `{path}`(`vscode://file//home/u/f.ts`——POSIX 绝对路径自带前导 `/`,与 VS Code 官方文档 `vscode://file/c:/…` 的约定一致;反斜杠归一化为 `/`)。
|
||||
- SSH(VSCode 系):`<scheme>://vscode-remote/ssh-remote+<host>/<path>`,路径保留前导 `/`(如 `…+dev/home/u/f.ts`,与社区约定一致)。
|
||||
- 模板无 `scheme://` 或无 `{path}` → 返回 undefined(不打开)。
|
||||
|
||||
## 菜单结构(文件行与目录行一致)
|
||||
|
||||
```
|
||||
在新 Tab 中打开 ← 既有
|
||||
在侧边打开 ← 既有
|
||||
----------------------------
|
||||
VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加)
|
||||
Cursor (SSH)
|
||||
----------------------------
|
||||
在应用中打开 > ← 子菜单父项(始终在 pinned 之后)
|
||||
资源管理器 📌
|
||||
VS Code 📌(激活)
|
||||
Cursor 📌(激活)
|
||||
Zed 📌
|
||||
Windsurf 📌
|
||||
下载 ← 既有(仅文件行)
|
||||
复制相对地址 / 复制绝对地址 ← 既有
|
||||
```
|
||||
|
||||
- 图钉是 `label` 内的 `<span role="button" tabIndex={-1}>`(Menu 行本身是 `<button>`,嵌套原生 button 不合法);`stopPropagation` + `preventDefault` 使其**不关闭菜单、不触发行选择**,点击后经响应式 blob 重渲染立即翻转。
|
||||
- 未接线(props 缺省)或 SSH 过滤后无目标 → 整个区块隐藏,旧行为/旧测试不变。
|
||||
|
||||
## 设置面板:`settings.render` seam 扩展
|
||||
|
||||
**实施偏差(与最初"render 独占"契约不同,需同步文档)**:`SettingsBody`(`src/client/SideCardSection.tsx`)原先"有 `render` 就整个替换行列表"。本功能需要**行列表(`editorExplorer` 下拉)与自定义面板共存**,因此把 seam 改为:
|
||||
|
||||
> `render` 给定时**追加**渲染在行列表(`toggles` / `pluginToggles`)之后;未声明行列表时与旧行为一致(单独渲染)。
|
||||
|
||||
- 仓库内无既有 render 使用者,`tests/side-card-section.spec.tsx` 无"render 独占"断言,风险可控;`AGENTS.md` §3.1/§5 与本文档同步更新。
|
||||
- 面板组件 `OpenWithSettings`:SSH host 输入 + 自定义编辑器行(名称/模板/VSCode 系/删除)+「添加」;每次变更整体提交 `updatePluginSetting('openWith', next)`(含 pinned 原样保留)。
|
||||
- 图钉写入走 `src/client/plugin-settings.ts` 的 `updatePluginSettings`:Promise 链串行化 + `api.settingsUpdate` 后 `store.setPrefs(parsePrefs(view.value))`,避免快速连点丢写。(设置弹窗内部有自己独立的串行 commit,二者极少重叠。)
|
||||
|
||||
## 宿主路由
|
||||
|
||||
`POST /sidebar/api/open.external`(`src/index.ts` `buildApi`,与其他方法同 fence + 错误包裹):
|
||||
|
||||
```ts
|
||||
{ action: 'reveal', path } // 在文件管理器中显示/选中
|
||||
{ action: 'url', url } // 交给注册的协议处理器
|
||||
```
|
||||
|
||||
- `src/open-external.ts`:纯函数 `revealCommand` / `urlCommand`(platform 可注入,单测覆盖三平台)+ `launchExternal`(`spawn(..., { detached: true, stdio: 'ignore' })` + `unref`)。
|
||||
- `reveal`:darwin `open -R`;win32 `explorer.exe /select,`;linux `xdg-open <父目录>`(无统一 select 协议,KISS 打开所在目录)。
|
||||
- `url`:darwin `open`;win32 `rundll32 url.dll,FileProtocolHandler`(备选 `cmd /c start ""`);linux `xdg-open`。
|
||||
- 校验:`reveal` 路径必须绝对(`requireAbsolute`);`url` 必须是 `scheme://` 自定义协议(拒绝 http/https)。
|
||||
- **Windows 平台命令为约定实现,需在 Windows 实机验证**(本机为 macOS;CI 覆盖 linux)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
|
||||
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。
|
||||
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
|
||||
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
|
||||
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
|
||||
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
|
||||
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
|
||||
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
|
||||
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
|
||||
- 全量 `pnpm test`(810 passed)、`pnpm typecheck`、`pnpm build` 全绿;未跑挂载冒烟(由 CI plugin-mount 门禁覆盖)。
|
||||
+2
-1
@@ -96,8 +96,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"react": "^18.2.0",
|
||||
@@ -133,6 +133,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
Generated
+12
@@ -83,6 +83,9 @@ importers:
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
react-icons:
|
||||
specifier: 5.7.0
|
||||
version: 5.7.0(react@18.2.0)
|
||||
rxjs:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
@@ -2302,6 +2305,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
react-icons@5.7.0:
|
||||
resolution: {integrity: sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react@18.2.0:
|
||||
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5068,6 +5076,10 @@ snapshots:
|
||||
react: 18.2.0
|
||||
scheduler: 0.23.0
|
||||
|
||||
react-icons@5.7.0(react@18.2.0):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
react@18.2.0:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { VscLinkExternal } from 'react-icons/vsc'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
@@ -186,7 +186,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
<VscLinkExternal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -33,6 +33,8 @@ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './edit
|
||||
import { baseName } from './FileTree.tsx'
|
||||
import { createFrameBatcher } from './frame-batcher.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
|
||||
import { updatePluginSettings } from './plugin-settings.ts'
|
||||
import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -52,6 +54,10 @@ const TREE_WIDTH_DEFAULT = 240
|
||||
const TREE_WIDTH_MIN = 160
|
||||
const TREE_WIDTH_MAX = 480
|
||||
|
||||
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
|
||||
* would change identity every snapshot and loop useSyncExternalStore). */
|
||||
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
|
||||
|
||||
/** The tab's persisted meta object (a malformed meta reads as empty). */
|
||||
function metaOf(tab: SidebarTab): Record<string, unknown> {
|
||||
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
|
||||
@@ -106,6 +112,16 @@ export function EditorHost(props: {
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
|
||||
)
|
||||
// The file tree's "open with" configuration (pluginSettings['editor']): a
|
||||
// blob subscription, so a pin click or a settings-page edit re-renders the
|
||||
// menu immediately. The parsed config also drives which targets are shown
|
||||
// (SSH mode hides the host-local ones).
|
||||
const editorBlob = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
|
||||
)
|
||||
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
||||
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
||||
// A path-less tab shows the empty-state hint in merged mode — and in split
|
||||
// mode it is the standalone explorer (tree-only, see the render below).
|
||||
const showEmpty = path === ''
|
||||
@@ -150,6 +166,39 @@ export function EditorHost(props: {
|
||||
})
|
||||
}
|
||||
|
||||
/** The context menu's "open with" action: reveal the path in the OS file
|
||||
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
|
||||
* form for VSCode-family editors in remote mode) to the host's external
|
||||
* opener. Failures are logged only — a missing handler is the OS's
|
||||
* dialog, not a sidebar error. */
|
||||
const openWith = (targetId: string, absolute: string): void => {
|
||||
const target = openWithTargets.find(item => item.id === targetId)
|
||||
if (target === undefined) return
|
||||
if (target.kind === 'reveal') {
|
||||
void api.openExternal({ action: 'reveal', path: absolute }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
return
|
||||
}
|
||||
const url = openWithUrl(target, absolute, openWithConfig)
|
||||
if (url === undefined) return
|
||||
void api.openExternal({ action: 'url', url }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle one target's pinned state. The write is serialized (see
|
||||
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
|
||||
const toggleOpenWithPin = (targetId: string): void => {
|
||||
updatePluginSettings(store, 'editor', (blob) => {
|
||||
const config = parseOpenWithConfig(blob.openWith)
|
||||
const pinned = config.pinned.includes(targetId)
|
||||
? config.pinned.filter(id => id !== targetId)
|
||||
: [...config.pinned, targetId]
|
||||
return { ...blob, openWith: { ...config, pinned } }
|
||||
})
|
||||
}
|
||||
|
||||
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
||||
// its state and registers its commands (both null/absent for viewers
|
||||
// without a toolbar — image, pdf, binary download).
|
||||
@@ -287,6 +336,11 @@ export function EditorHost(props: {
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
@@ -380,6 +434,11 @@ export function EditorHost(props: {
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+116
-13
@@ -24,11 +24,14 @@ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEve
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
@@ -109,6 +112,19 @@ export function FileTree(props: {
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** Context-menu "open to the side" (file rows; absent → no entry). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/**
|
||||
* The "open with" menu: resolved external targets (already SSH-filtered
|
||||
* and in menu order). Absent → the whole section is hidden.
|
||||
*/
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
/** Ids of targets pinned to the menu's top level (subset of the ids). */
|
||||
openWithPinned?: string[]
|
||||
/** Whether the workspace is remote (appends the SSH hint to target labels). */
|
||||
openWithSsh?: boolean
|
||||
/** Open one target externally (reveal or URL — the caller decides). */
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
@@ -118,7 +134,7 @@ export function FileTree(props: {
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
@@ -307,6 +323,88 @@ export function FileTree(props: {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
/** The menu label of one open target: a locale key for the built-ins, the
|
||||
* user's own name for custom editors, plus the SSH hint in remote mode. */
|
||||
const openWithLabelOf = (target: OpenWithTarget): string => {
|
||||
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
|
||||
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The "open with" menu entries: the pinned targets as DIRECT rows, then
|
||||
* the parent row with every target as a nested submenu. Both only render
|
||||
* when the caller wired the feature and at least one target is visible.
|
||||
*/
|
||||
const openWithEntries = (): MenuEntry[] => {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
.map<MenuItem>(target => ({
|
||||
id: `open-with:${target.id}`,
|
||||
label: openWithLabelOf(target),
|
||||
icon: itemIcon(target),
|
||||
}))
|
||||
const submenu = openWithTargets.map<MenuItem>(target => {
|
||||
const pinnedNow = pinnedIds.includes(target.id)
|
||||
return {
|
||||
id: `open-with:${target.id}`,
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
|
||||
{/* The pushpin: a span (never a button — the Menu row itself is
|
||||
a button, so a nested interactive element would be invalid).
|
||||
Clicking it pins/unpins the target at the menu's top level
|
||||
WITHOUT selecting the row: the pin stops propagation, so the
|
||||
menu stays open and the icon flips on the next render. */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
|
||||
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
icon: itemIcon(target),
|
||||
}
|
||||
})
|
||||
return [
|
||||
...pinned,
|
||||
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
|
||||
{
|
||||
id: 'open-with-menu',
|
||||
// The primitives Menu renders no chevron for submenu parents — the
|
||||
// trailing arrow is supplied inside the label (full-width flex row,
|
||||
// right-aligned), matching how the submenu rows right-align the pin.
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
@@ -346,7 +444,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -377,7 +475,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<VscFile size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -406,7 +504,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<VscFolderOpened size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
@@ -498,21 +596,22 @@ export function FileTree(props: {
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -526,6 +625,10 @@ export function FileTree(props: {
|
||||
onOpenFileSide?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id.startsWith('open-with:')) {
|
||||
onOpenWith?.(id.slice('open-with:'.length), target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
|
||||
@@ -893,3 +893,68 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Open-with settings panel: one custom editor row (name + URL template +
|
||||
VSCode-family checkbox + remove) and the row-validity hint. */
|
||||
.openWithEditorRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openWithEditorInput,
|
||||
.openWithEditorTemplate {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.openWithEditorInput:focus-visible,
|
||||
.openWithEditorTemplate:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.openWithFamily {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithRemove:hover {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.openWithHint {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@@ -492,12 +492,13 @@ function SelectRow(props: {
|
||||
|
||||
/**
|
||||
* The secondary settings popup body of one feature (tab or viewer):
|
||||
* - `settings.render` (custom panel) when declared — rendered with the
|
||||
* shared store/service, the live prefs, the descriptor's own plugin
|
||||
* settings blob, a persistence helper, and a close callback;
|
||||
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
|
||||
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
|
||||
* projected onto the prefs face so the shared row renderer reads them).
|
||||
* - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
|
||||
* rows (their values live in `pluginSettings[feature.id]`, projected onto
|
||||
* the prefs face so the shared row renderer reads them);
|
||||
* - `settings.render` (custom panel) AFTER those rows when declared — the
|
||||
* custom panel is an extension of the row list, not a replacement, so a
|
||||
* feature can keep its declarative rows (e.g. the editor's
|
||||
* open-behavior picker) and still ship a custom configuration area.
|
||||
*/
|
||||
export function SettingsBody(props: {
|
||||
feature: TabDescriptor | FileViewerDescriptor
|
||||
@@ -515,48 +516,50 @@ export function SettingsBody(props: {
|
||||
}) {
|
||||
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
|
||||
const render = feature.settings?.render
|
||||
if (render !== undefined) {
|
||||
return (
|
||||
<SettingsRender
|
||||
render={render}
|
||||
renderProps={{
|
||||
store,
|
||||
service,
|
||||
prefs,
|
||||
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
||||
updatePluginSetting: onPluginWrite,
|
||||
close: onClose,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toggles = feature.settings?.toggles ?? []
|
||||
const pluginToggles = feature.settings?.pluginToggles ?? []
|
||||
if (toggles.length === 0 && pluginToggles.length === 0) return null
|
||||
if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
|
||||
// Plugin rows read their values from the descriptor's OWN blob through
|
||||
// an explicit value source — no projection onto the prefs face, so a
|
||||
// plugin key can never collide with (or silently read) a host pref of
|
||||
// the same name.
|
||||
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={toggles}
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
<div>
|
||||
{(toggles.length > 0 || pluginToggles.length > 0) && (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={toggles}
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={pluginToggles}
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={pluginToggles}
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
{render !== undefined && (
|
||||
<SettingsRender
|
||||
render={render}
|
||||
renderProps={{
|
||||
store,
|
||||
service,
|
||||
prefs,
|
||||
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
||||
updatePluginSetting: onPluginWrite,
|
||||
close: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1045,9 +1048,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
feature is open — the Modal primitive runs hooks unconditionally,
|
||||
so a closed-but-mounted Modal would break SSR (and the
|
||||
renderToString spec) under the test dual-react split.
|
||||
Content: `settings.render` (custom panel) when declared, else the
|
||||
host-prefs `toggles` rows followed by the plugin-owned
|
||||
`pluginToggles` rows (their values live in pluginSettings[id]). */}
|
||||
Content: the host-prefs `toggles` rows, the plugin-owned
|
||||
`pluginToggles` rows (their values live in pluginSettings[id]),
|
||||
then the custom `settings.render` panel when declared. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-
|
||||
import { api } from './api.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { UploadOverlay } from './UploadOverlay.tsx'
|
||||
@@ -50,12 +51,19 @@ export function TreePanel(props: {
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** File context-menu "open to the side" (passed through to FileTree). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/** The "open with" menu surface (passed through to FileTree; absent →
|
||||
* the whole section is hidden). */
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -217,6 +225,11 @@ export function TreePanel(props: {
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithPinned}
|
||||
openWithSsh={openWithSsh}
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
|
||||
@@ -289,6 +289,12 @@ export const api = {
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
/** External open for the file tree's "open with" menu: reveal a path in
|
||||
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
|
||||
* zed://, custom editors) to its registered handler. The host launches
|
||||
* the platform opener (argv, no shell). */
|
||||
openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
|
||||
call<{ started: boolean }>('open.external', payload),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { OpenWithSettings } from '../open-with-settings.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
@@ -90,7 +91,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
dedupeKey: (tab) => tab.path,
|
||||
// Declarative settings: the file-open behavior picker (in-place switch
|
||||
// vs per-path windows) renders as an iconed select row under the
|
||||
// editor card's gear in the Side card settings page.
|
||||
// editor card's gear in the Side card settings page; the "open with"
|
||||
// configuration (SSH host + custom editors) is the custom panel BELOW
|
||||
// those rows — the settings seam renders rows first, custom panel after.
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'editorExplorer',
|
||||
@@ -112,6 +115,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
},
|
||||
],
|
||||
}],
|
||||
render: ({ pluginSettings, updatePluginSetting }) => (
|
||||
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
|
||||
),
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<EditorHost
|
||||
|
||||
@@ -137,3 +137,16 @@ export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Visual Studio Code brand mark for the file-tree "open with" menu. The
|
||||
* path is the Simple Icons `visualstudiocode` glyph (CC0 1.0,
|
||||
* simple-icons@11.0.0 — later releases dropped it over Microsoft's brand
|
||||
* policy, so it is inlined here rather than pulled from react-icons),
|
||||
* rendered monochrome via currentColor to follow the active skin.
|
||||
*/
|
||||
export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,26 @@ export const zh = {
|
||||
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
|
||||
openFileNewTab: '在新 Tab 中打开',
|
||||
openFileSide: '在侧边打开',
|
||||
openWithMenu: '在应用中打开',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: '固定到菜单',
|
||||
unpinOpenWith: '取消固定',
|
||||
openWithExplorer: '资源管理器',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'SSH 远端主机',
|
||||
openWithSettingsSshDesc: '留空为本地工作区;填入 user@host 或 SSH 别名后,VSCode 系打开方式将改用 vscode-remote/ssh-remote 协议,资源管理器 / Zed / 非 VSCode 系自定义编辑器将从菜单隐藏',
|
||||
openWithSettingsSshPlaceholder: 'user@host 或 SSH 别名',
|
||||
openWithSettingsCustomTitle: '自定义编辑器',
|
||||
openWithSettingsCustomDesc: '名称 + URL 模板({path} 占位符)+ 是否 VSCode 系;SSH 模式下仅 VSCode 系可打开远端',
|
||||
openWithSettingsAdd: '添加',
|
||||
openWithSettingsName: '名称',
|
||||
openWithSettingsTemplate: '如 cursor://file/{path}',
|
||||
openWithSettingsFamily: 'VSCode 系',
|
||||
openWithSettingsFamilyDesc: '该编辑器使用 VSCode 的 URL 协议(支持 SSH 远端打开)',
|
||||
openWithSettingsRemove: '删除',
|
||||
openWithSettingsInvalidHint: '名称或模板(需含 {path} 且以 scheme:// 开头)未填写的编辑器不会出现在菜单中',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
brokenSymlink: '失效的软链接',
|
||||
@@ -340,6 +360,26 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
|
||||
openFileNewTab: 'Open in New Tab',
|
||||
openFileSide: 'Open to the Side',
|
||||
openWithMenu: 'Open with',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: 'Pin to menu',
|
||||
unpinOpenWith: 'Unpin',
|
||||
openWithExplorer: 'File Manager',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'SSH remote host',
|
||||
openWithSettingsSshDesc: 'Empty = local workspace; with a user@host or SSH alias, VSCode-family openers switch to the vscode-remote/ssh-remote protocol and the File Manager / Zed / non-VSCode-family custom editors are hidden from the menu',
|
||||
openWithSettingsSshPlaceholder: 'user@host or SSH alias',
|
||||
openWithSettingsCustomTitle: 'Custom editors',
|
||||
openWithSettingsCustomDesc: 'Name + URL template ({path} placeholder) + VSCode-family flag; in remote mode only VSCode-family editors can open a remote path',
|
||||
openWithSettingsAdd: 'Add',
|
||||
openWithSettingsName: 'Name',
|
||||
openWithSettingsTemplate: 'e.g. cursor://file/{path}',
|
||||
openWithSettingsFamily: 'VSCode-family',
|
||||
openWithSettingsFamilyDesc: 'This editor speaks the VSCode URL dialect (supports SSH-remote opens)',
|
||||
openWithSettingsRemove: 'Remove',
|
||||
openWithSettingsInvalidHint: 'Editors with a missing name or a template without {path} / scheme:// are not shown in the menu',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Broken symlink',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The editor tab's custom settings panel ("打开方式"): the file tree's
|
||||
* "open with" configuration — the optional SSH host marking the workspace as
|
||||
* remote, and the user-defined editors (name + URL template with `{path}` +
|
||||
* whether they speak the VSCode URL dialect). Persisted as the editor
|
||||
* blob's `openWith` key through the settings popup's `updatePluginSetting`.
|
||||
*
|
||||
* The popup renders the declarative rows (the editorExplorer picker) ABOVE
|
||||
* this panel — SettingsBody renders the custom panel after the row list, so
|
||||
* this component owns only its own section.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
parseOpenWithConfig,
|
||||
type CustomEditor,
|
||||
type OpenWithConfig,
|
||||
} from './open-with.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
export function OpenWithSettings(props: {
|
||||
pluginSettings: Record<string, unknown>
|
||||
updatePluginSetting: (key: string, value: unknown) => void
|
||||
}) {
|
||||
const { pluginSettings, updatePluginSetting } = props
|
||||
// Local draft seeded from the persisted blob (each commit re-renders the
|
||||
// popup with the round-tripped prefs; the local state stays authoritative
|
||||
// while the popup is open, so typing never fights an in-flight write).
|
||||
const [draft, setDraft] = useState<OpenWithConfig>(() => parseOpenWithConfig(pluginSettings.openWith))
|
||||
|
||||
const commit = (next: OpenWithConfig): void => {
|
||||
setDraft(next)
|
||||
updatePluginSetting('openWith', next)
|
||||
}
|
||||
|
||||
const setSshHost = (sshHost: string): void => commit({ ...draft, sshHost })
|
||||
|
||||
const patchCustom = (id: string, patch: Partial<CustomEditor>): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: draft.customEditors.map(editor => editor.id === id ? { ...editor, ...patch } : editor),
|
||||
})
|
||||
}
|
||||
|
||||
const removeCustom = (id: string): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: draft.customEditors.filter(editor => editor.id !== id),
|
||||
// A removed editor can never stay pinned (the menu prunes unknown ids
|
||||
// anyway; this keeps the blob clean).
|
||||
pinned: draft.pinned.filter(pinnedId => pinnedId !== `custom:${id}`),
|
||||
})
|
||||
}
|
||||
|
||||
const addCustom = (): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: [...draft.customEditors, { id: newCustomEditorId(), name: '', urlTemplate: '', isVscodeFamily: false }],
|
||||
})
|
||||
}
|
||||
|
||||
const hasInvalid = draft.customEditors.some(editor => !isValidCustomEditor(editor))
|
||||
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('openWithSettingsSshTitle')}</span>
|
||||
<span className={css.desc}>{t('openWithSettingsSshDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
className={css.typedInput}
|
||||
value={draft.sshHost}
|
||||
placeholder={t('openWithSettingsSshPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setSshHost(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('openWithSettingsCustomTitle')}</span>
|
||||
<span className={css.desc}>{t('openWithSettingsCustomDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<button type="button" className={css.done} onClick={addCustom}>
|
||||
{t('openWithSettingsAdd')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{draft.customEditors.map(editor => (
|
||||
<div key={editor.id} className={css.openWithEditorRow}>
|
||||
<input
|
||||
className={css.openWithEditorInput}
|
||||
value={editor.name}
|
||||
placeholder={t('openWithSettingsName')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { patchCustom(editor.id, { name: event.target.value }) }}
|
||||
/>
|
||||
<input
|
||||
className={css.openWithEditorTemplate}
|
||||
value={editor.urlTemplate}
|
||||
placeholder={t('openWithSettingsTemplate')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { patchCustom(editor.id, { urlTemplate: event.target.value }) }}
|
||||
/>
|
||||
<label className={css.openWithFamily} title={t('openWithSettingsFamilyDesc')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.isVscodeFamily}
|
||||
onChange={(event) => { patchCustom(editor.id, { isVscodeFamily: event.currentTarget.checked }) }}
|
||||
/>
|
||||
<span>{t('openWithSettingsFamily')}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={css.openWithRemove}
|
||||
aria-label={t('openWithSettingsRemove')}
|
||||
title={t('openWithSettingsRemove')}
|
||||
onClick={() => { removeCustom(editor.id) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{hasInvalid && (
|
||||
<div className={css.openWithHint} role="note">
|
||||
{t('openWithSettingsInvalidHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The file tree's "open with" vocabulary: which external editors / file
|
||||
* managers the context menu can hand a row's path to, how the per-user
|
||||
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
|
||||
* persisted blob, and how the external URL is built (local `file` URLs and
|
||||
* VSCode-family SSH-remote URLs).
|
||||
*
|
||||
* Pure by design (no React / no `api`), so the whole surface is unit-testable
|
||||
* without a DOM or a host route. The menu and the settings panel only consume
|
||||
* the exported types and functions.
|
||||
*/
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
|
||||
export interface CustomEditor {
|
||||
id: string
|
||||
/** Human label shown in the menu (e.g. "Windsurf"). */
|
||||
name: string
|
||||
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
|
||||
urlTemplate: string
|
||||
/** Whether the editor speaks the VSCode URL dialect — the only editors
|
||||
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
|
||||
isVscodeFamily: boolean
|
||||
}
|
||||
|
||||
/** The per-user "open with" configuration (one key of the editor blob). */
|
||||
export interface OpenWithConfig {
|
||||
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
|
||||
sshHost: string
|
||||
/** User-defined editors, appended after the built-ins. */
|
||||
customEditors: CustomEditor[]
|
||||
/** Ids of open-with targets pinned to the menu's top level
|
||||
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
|
||||
pinned: string[]
|
||||
}
|
||||
|
||||
/** One menu-visible open target (built-in or custom, SSH-filtered). */
|
||||
export interface OpenWithTarget {
|
||||
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
|
||||
id: string
|
||||
/** Locale key of a built-in label (custom editors carry `name` instead). */
|
||||
nameKey?: CopyKey
|
||||
/** User-defined label (custom editors only; '' for built-ins). */
|
||||
name: string
|
||||
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
|
||||
kind: 'reveal' | 'url'
|
||||
/** URL template with `{path}`; undefined for reveal targets. */
|
||||
urlTemplate?: string
|
||||
/** Whether the editor talks the VSCode URL dialect. */
|
||||
isVscodeFamily: boolean
|
||||
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
|
||||
localOnly: boolean
|
||||
}
|
||||
|
||||
/** The default open-with configuration (fresh documents). */
|
||||
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
|
||||
sshHost: '',
|
||||
customEditors: [],
|
||||
pinned: [],
|
||||
}
|
||||
|
||||
/** The built-in open targets, in menu order. */
|
||||
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
|
||||
{
|
||||
id: 'explorer',
|
||||
nameKey: 'openWithExplorer',
|
||||
name: '',
|
||||
kind: 'reveal',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
nameKey: 'openWithVscode',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'vscode://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
nameKey: 'openWithCursor',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'cursor://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
nameKey: 'openWithZed',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'zed://file/{path}',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a persisted value makes a structurally valid custom-editor row.
|
||||
* Name/template may be empty — the settings panel edits rows in place and
|
||||
* an in-progress row must survive the round-trip; the MENU hides rows that
|
||||
* fail the stricter {@link isValidCustomEditor} check. */
|
||||
function isCustomEditor(value: unknown): value is CustomEditor {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.id === 'string' && record.id !== ''
|
||||
&& typeof record.name === 'string'
|
||||
&& typeof record.urlTemplate === 'string'
|
||||
&& typeof record.isVscodeFamily === 'boolean'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
|
||||
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
|
||||
* are kept verbatim (unknown ids are pruned when the targets are resolved —
|
||||
* the menu is the only consumer of the resolved list).
|
||||
*/
|
||||
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
|
||||
const record = raw as Record<string, unknown>
|
||||
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
|
||||
const customEditors = Array.isArray(record.customEditors)
|
||||
? record.customEditors.filter(isCustomEditor)
|
||||
: []
|
||||
const pinned = Array.isArray(record.pinned)
|
||||
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
: []
|
||||
return { sshHost, customEditors, pinned }
|
||||
}
|
||||
|
||||
/** Whether a custom editor id belongs to this config (id prefix match). */
|
||||
function customIdOf(id: string): string {
|
||||
return `custom:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu-visible open targets, in order (built-ins then custom editors).
|
||||
* In SSH mode the local-only targets (the OS file manager, Zed, custom
|
||||
* editors without the VSCode dialect) are dropped — they cannot reach a
|
||||
* remote path. Unknown pinned ids are pruned here too.
|
||||
*/
|
||||
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
|
||||
const ssh = config.sshHost.trim() !== ''
|
||||
const targets: OpenWithTarget[] = [
|
||||
...OPEN_WITH_BUILTINS,
|
||||
...config.customEditors
|
||||
.filter(isValidCustomEditor)
|
||||
.map((editor): OpenWithTarget => ({
|
||||
id: customIdOf(editor.id),
|
||||
name: editor.name,
|
||||
kind: 'url',
|
||||
urlTemplate: editor.urlTemplate,
|
||||
isVscodeFamily: editor.isVscodeFamily,
|
||||
localOnly: !editor.isVscodeFamily,
|
||||
})),
|
||||
]
|
||||
return targets.filter(target => !(ssh && target.localOnly))
|
||||
}
|
||||
|
||||
/** The SSH hint appended to a target's label in remote mode. */
|
||||
export function openWithSshActive(config: OpenWithConfig): boolean {
|
||||
return config.sshHost.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to open for one resolved target, or undefined when the target has
|
||||
* no URL form (reveal) or the template is malformed. The path is inserted
|
||||
* RAW into the template (browsers percent-encode as needed; VSCode-family
|
||||
* URL parsers consume the absolute path with its leading slash, e.g.
|
||||
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
|
||||
*/
|
||||
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
|
||||
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
|
||||
const normalized = normalizeUrlPath(path)
|
||||
const ssh = openWithSshActive(config)
|
||||
if (ssh && target.isVscodeFamily) {
|
||||
const scheme = schemeOf(target.urlTemplate)
|
||||
if (scheme === undefined) return undefined
|
||||
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
|
||||
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
|
||||
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
|
||||
}
|
||||
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
|
||||
return target.urlTemplate.replace('{path}', normalized)
|
||||
}
|
||||
|
||||
/** Whether a template starts with a `scheme://` prefix (the only shape the
|
||||
* host's external opener accepts and the settings panel suggests). */
|
||||
function hasUrlScheme(template: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
|
||||
}
|
||||
|
||||
/** The scheme of a URL template (the part before the first ':'), or undefined. */
|
||||
function schemeOf(template: string): string | undefined {
|
||||
const at = template.indexOf(':')
|
||||
if (at <= 0) return undefined
|
||||
const scheme = template.slice(0, at)
|
||||
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
|
||||
}
|
||||
|
||||
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
|
||||
export function normalizeUrlPath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/** A fresh custom-editor id (uuid when available, time-based fallback). */
|
||||
export function newCustomEditorId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** Validate one custom-editor row before the settings panel accepts it. */
|
||||
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
|
||||
return row.name.trim() !== ''
|
||||
&& row.urlTemplate.includes('{path}')
|
||||
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Pending-writes queue for the file tree's open-with config: pin toggles and
|
||||
* (outside the settings popup) config edits land in the sidebar prefs as
|
||||
* `pluginSettings['editor']`. Writes are serialized through one promise chain
|
||||
* so a quick burst of pin clicks can never read a stale pluginSettings map
|
||||
* and drop an earlier toggle; each write pushes the whole open map patch
|
||||
* through the revision-free settings route and adopts the returned document.
|
||||
*
|
||||
* (The settings popup has its own serialized commit — SideCardSection's —
|
||||
* so its rows and this helper rarely race; the shared route's last-write-wins
|
||||
* semantics cover the uncommon overlap.)
|
||||
*/
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Merge one plugin-owned settings blob of one descriptor and persist it.
|
||||
* @param store - the sidebar store (its prefs are replaced by the write result).
|
||||
* @param descriptorId - the descriptor whose blob is patched ('editor' here).
|
||||
* @param updater - pure patch function; receives a shallow copy of the blob.
|
||||
*/
|
||||
export function updatePluginSettings(
|
||||
store: SidebarStore,
|
||||
descriptorId: string,
|
||||
updater: (blob: Record<string, unknown>) => Record<string, unknown>,
|
||||
): void {
|
||||
queue = queue.then(async () => {
|
||||
const prefs = store.getPrefs()
|
||||
const blob = prefs.pluginSettings[descriptorId] ?? {}
|
||||
const next = updater({ ...blob })
|
||||
const view = await api.settingsUpdate({
|
||||
pluginSettings: { ...prefs.pluginSettings, [descriptorId]: next },
|
||||
})
|
||||
store.setPrefs(parsePrefs(view.value))
|
||||
}).catch((error: unknown) => {
|
||||
// The pin stays visually unchanged (no optimistic flip) and the menu
|
||||
// keeps working — the write failure is logged, not surfaced.
|
||||
console.error('open-with settings write failed', error)
|
||||
})
|
||||
}
|
||||
@@ -2521,3 +2521,54 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
max-width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The file tree "open with" menu rows: the label area is a BLOCK-level flex
|
||||
row that fills the primitives `.itemLabel` span (which is itself `flex: 1`
|
||||
inside the row), so the trailing control — the pushpin on submenu rows,
|
||||
the chevron on the submenu parent — docks at the row's far right, exactly
|
||||
like the trailing check on selected rows of every other menu. */
|
||||
.openWithLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openWithName {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The trailing "expands into a submenu" chevron on the open-with parent row
|
||||
(the primitives Menu renders no arrow for submenu parents). */
|
||||
.openWithChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The pushpin toggle on an "open with" submenu row: brand ink once the
|
||||
target is pinned to the menu's top level. */
|
||||
.openWithPin {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithPin:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.openWithPinActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
||||
@@ -479,6 +480,19 @@ function buildApi(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
// External open for the file tree's "open with" menu: reveal a path in
|
||||
// the OS file manager, or hand a custom-scheme URL (vscode://,
|
||||
// cursor://, zed://, custom editors) to its registered handler. The
|
||||
// client is a browser renderer where raw scheme navigation is
|
||||
// unreliable, so the launch always goes through the host — the same
|
||||
// fence as every other route, argv-only (no shell interpolation).
|
||||
'open.external': (payload) => {
|
||||
const record = payload as { action?: unknown } | null
|
||||
const action = record?.action
|
||||
if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
|
||||
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
|
||||
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
|
||||
},
|
||||
// Side Chat: create a side-thread child seeded with the parent's full
|
||||
// log up to now, deliver follow-ups (cold-resuming when the thread's
|
||||
// agent is gone), abort a running thread, and release a thread's agent.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* External open actions for the file tree's "open with" menu: hand a path to
|
||||
* the OS file manager (reveal/select) or launch a URL scheme's registered
|
||||
* handler (vscode://, cursor://, zed://, custom schemes).
|
||||
*
|
||||
* The client runs in a browser / DSH Desktop renderer where a raw `vscode://`
|
||||
* navigation is unreliable, so both actions fan out through this host route
|
||||
* and spawn the platform opener with an argv array (no shell interpolation).
|
||||
* The command builders are pure — the platform is injectable — so every
|
||||
* per-platform branch is unit-testable without spawning anything.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { parentOf, requireAbsolute } from './fs-tree.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** The two external open actions the route accepts. */
|
||||
export type OpenExternalAction = 'reveal' | 'url'
|
||||
|
||||
/** One platform opener invocation (argv array — never a shell string). */
|
||||
export interface ExternalCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Reveal/select a path in the OS file manager. On Linux there is no common
|
||||
* select protocol — the containing directory is opened instead (KISS). */
|
||||
export function revealCommand(path: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return { command: 'open', args: ['-R', path] }
|
||||
// `explorer /select,<path>` — the classic "show in folder" incantation.
|
||||
// Passed as separate argv entries (no shell); verify on Windows, with
|
||||
// `cmd /c start "" explorer.exe "/select,<path>"` as the fallback.
|
||||
case 'win32':
|
||||
return { command: 'explorer.exe', args: ['/select,', path] }
|
||||
default: {
|
||||
const parent = parentOf(path)
|
||||
return { command: 'xdg-open', args: [parent ?? path] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a custom-scheme URL to the OS protocol handler. */
|
||||
export function urlCommand(url: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return { command: 'open', args: [url] }
|
||||
// url.dll,FileProtocolHandler launches the registered protocol handler;
|
||||
// `cmd /c start "" <url>` is the fallback if rundll32 misbehaves.
|
||||
case 'win32':
|
||||
return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
|
||||
default:
|
||||
return { command: 'xdg-open', args: [url] }
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a URL-scheme open target: a parseable custom-scheme URL (never
|
||||
* http/https — those would only dump the URL into a browser tab). */
|
||||
export function validateExternalUrl(raw: string): string {
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
||||
throw new SidebarError('bad-request', 'url must be a custom-scheme URL')
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', 'invalid url')
|
||||
}
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
throw new SidebarError('bad-request', 'only custom-scheme urls can be opened externally')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch one external open action and return immediately (detached, no
|
||||
* stdio). Spawn failures are reported through the child's 'error' event —
|
||||
* by then the route already returned, so the event is swallowed (the OS
|
||||
* dialog about a missing handler is the user-visible outcome either way).
|
||||
*/
|
||||
export function launchExternal(action: OpenExternalAction, value: string): { started: true } {
|
||||
const platform = process.platform
|
||||
const spec = action === 'reveal'
|
||||
? revealCommand(requireAbsolute(value), platform)
|
||||
: urlCommand(validateExternalUrl(value), platform)
|
||||
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore' })
|
||||
child.on('error', () => { /* opener missing/denied: handled by the OS */ })
|
||||
child.unref()
|
||||
return { started: true }
|
||||
}
|
||||
@@ -94,6 +94,9 @@ describe('built-in tab registrations', () => {
|
||||
const options = toggles[0]?.options ?? []
|
||||
expect(options.map(o => o.value)).toEqual([true, false])
|
||||
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
|
||||
// The open-with configuration (SSH host + custom editors) is the custom
|
||||
// panel rendered below the declarative rows.
|
||||
expect(service.getTab('editor')?.settings?.render).toBeDefined()
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* FileTree "open with" context menu: a file row's right-click menu gains the
|
||||
* pinned direct rows and the parent submenu row; the submenu lists every
|
||||
* resolved target with a per-row pushpin, pinning never selects/closes the
|
||||
* menu, and selecting a child invokes the caller's open handler with the
|
||||
* row's absolute path. When the caller wires nothing, the section is absent.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { OpenWithTarget } from '../src/client/open-with.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
|
||||
}),
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
}))
|
||||
|
||||
/** The resolved target list a caller (EditorHost) would hand FileTree. */
|
||||
const targets: OpenWithTarget[] = [
|
||||
{ id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'vscode', nameKey: 'openWithVscode', name: '', kind: 'url', urlTemplate: 'vscode://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'cursor', nameKey: 'openWithCursor', name: '', kind: 'url', urlTemplate: 'cursor://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'zed', nameKey: 'openWithZed', name: '', kind: 'url', urlTemplate: 'zed://file/{path}', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false },
|
||||
]
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
onOpenWith: ReturnType<typeof vi.fn>
|
||||
onToggleOpenWithPin: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
async function mountTree(overrides: {
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
} = {}): Promise<Harness> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const onOpenWith = vi.fn()
|
||||
const onToggleOpenWithPin = vi.fn()
|
||||
// Property-presence semantics: an explicit `openWithTargets: undefined`
|
||||
// must reach FileTree (the section hides) instead of falling back.
|
||||
const has = (key: 'openWithTargets' | 'openWithPinned' | 'openWithSsh'): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(overrides, key)
|
||||
await act(async () => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onOpenFileNewTab: () => {},
|
||||
onOpenFileSide: () => {},
|
||||
openWithTargets: has('openWithTargets') ? overrides.openWithTargets : targets,
|
||||
openWithPinned: has('openWithPinned') ? overrides.openWithPinned : ['vscode'],
|
||||
openWithSsh: has('openWithSsh') ? overrides.openWithSsh : false,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
onReferenceFile: () => {},
|
||||
refreshTick: 0,
|
||||
onUploadRequest: () => {},
|
||||
busy: false,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
/** The file row of the one-level tree (role="button" with the name span). */
|
||||
function fileRow(container: HTMLDivElement): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
|
||||
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
|
||||
if (row === undefined) throw new Error('file row not found')
|
||||
return row
|
||||
}
|
||||
|
||||
/** Open the row's context menu at a fixed cursor position. */
|
||||
function openMenu(container: HTMLDivElement): void {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
|
||||
act(() => { fileRow(container).dispatchEvent(event) })
|
||||
}
|
||||
|
||||
describe('FileTree open-with menu', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the pinned direct row and the submenu parent for a file row', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
// The pinned VS Code sits at the top level (before the submenu parent).
|
||||
expect(items.map(item => item.textContent?.trim())).toContain('VS Code')
|
||||
const parent = items.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent?.textContent).toContain('Open with')
|
||||
// The submenu parent carries the trailing chevron affordance (the
|
||||
// primitives Menu renders no arrow of its own), right-aligned inside a
|
||||
// full-width label row — the same structure the submenu children use.
|
||||
expect(parent?.querySelector('[class*="openWithChevron"]')).not.toBeNull()
|
||||
expect(parent?.querySelector('[class*="openWithLabel"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens the submenu on click and lists every target with pin toggles', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent).toBeDefined()
|
||||
act(() => { parent!.click() })
|
||||
// Sub child rows = menuitems inside the nested menu.
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
|
||||
])
|
||||
expect(children.every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
|
||||
// Pinned state is announced per row (and swaps the pushpin glyph).
|
||||
const vscodeRow = children.find(item => item.textContent?.trim() === 'VS Code')
|
||||
const cursorRow0 = children.find(item => item.textContent?.trim() === 'Cursor')
|
||||
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
|
||||
expect(cursorRow0?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pin click toggles without selecting the row or closing the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const cursorRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Cursor')
|
||||
const pin = cursorRow!.querySelector<HTMLElement>('[class*="openWithPin"]')
|
||||
expect(pin).not.toBeNull()
|
||||
act(() => { pin!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
|
||||
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('cursor')
|
||||
expect(harness.onOpenWith).not.toHaveBeenCalled()
|
||||
// The menu (and the submenu) stayed open.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('selecting a submenu child invokes onOpenWith with the row path and closes the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Zed')
|
||||
act(() => { zedRow!.click() })
|
||||
expect(harness.onOpenWith).toHaveBeenCalledWith('zed', '/tmp/a.ts')
|
||||
// Selecting closes the row menu entirely.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).toBeNull()
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
|
||||
harness = await mountTree({
|
||||
openWithSsh: true,
|
||||
openWithTargets: targets.filter(target => !target.localOnly),
|
||||
openWithPinned: [],
|
||||
})
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
|
||||
])
|
||||
})
|
||||
|
||||
it('hides the whole section when the caller wires no targets', async () => {
|
||||
harness = await mountTree({ openWithTargets: undefined })
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false)
|
||||
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Host external-open helpers: per-platform opener commands (pure, injected
|
||||
* platform) and the URL validation that guards the spawn route. The actual
|
||||
* spawns are not exercised — the route runs in the DSH host process and the
|
||||
* OS outcome is not testable here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { launchExternal, revealCommand, urlCommand, validateExternalUrl } from '../src/open-external.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
|
||||
describe('revealCommand', () => {
|
||||
it('darwin: `open -R <path>` selects the file in Finder', () => {
|
||||
expect(revealCommand('/a/b.txt', 'darwin')).toEqual({ command: 'open', args: ['-R', '/a/b.txt'] })
|
||||
})
|
||||
|
||||
it('win32: `explorer /select,<path>` selects the file', () => {
|
||||
expect(revealCommand('C:\\a\\b.txt', 'win32')).toEqual({ command: 'explorer.exe', args: ['/select,', 'C:\\a\\b.txt'] })
|
||||
})
|
||||
|
||||
it('linux: `xdg-open` opens the containing directory (no common select protocol)', () => {
|
||||
expect(revealCommand('/a/b.txt', 'linux')).toEqual({ command: 'xdg-open', args: ['/a'] })
|
||||
expect(revealCommand('/', 'linux')).toEqual({ command: 'xdg-open', args: ['/'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlCommand', () => {
|
||||
it('darwin: `open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('vscode://file/x', 'darwin')).toEqual({ command: 'open', args: ['vscode://file/x'] })
|
||||
})
|
||||
|
||||
it('win32: rundll32 url.dll,FileProtocolHandler <url>', () => {
|
||||
expect(urlCommand('cursor://file/x', 'win32')).toEqual({
|
||||
command: 'rundll32.exe',
|
||||
args: ['url.dll,FileProtocolHandler', 'cursor://file/x'],
|
||||
})
|
||||
})
|
||||
|
||||
it('linux: `xdg-open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('zed://file/x', 'linux')).toEqual({ command: 'xdg-open', args: ['zed://file/x'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExternalUrl', () => {
|
||||
it('accepts custom-scheme URLs (incl. the SSH-remote form)', () => {
|
||||
expect(validateExternalUrl('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts'))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(validateExternalUrl('myapp://file/{path}')).toBe('myapp://file/{path}')
|
||||
})
|
||||
|
||||
it('rejects http/https (only custom schemes make sense here)', () => {
|
||||
expect(() => validateExternalUrl('https://example.com')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('http://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects non-URL / non-`scheme://` strings', () => {
|
||||
expect(() => validateExternalUrl('/home/u/f.ts')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('a:file/x')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchExternal validation (pre-spawn)', () => {
|
||||
it('rejects relative reveal paths before anything is spawned', () => {
|
||||
expect(() => launchExternal('reveal', 'relative/path')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects invalid URLs before anything is spawned', () => {
|
||||
expect(() => launchExternal('url', 'https://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The editor's "open with" settings panel: the SSH host row and the custom
|
||||
* editor list (name + URL template + VSCode-family flag, add/remove). Every
|
||||
* edit commits the whole `openWith` blob through the popup's
|
||||
* updatePluginSetting; removing an editor also prunes its pinned id.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { OpenWithSettings } from '../src/client/open-with-settings.tsx'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
update: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountSettings(rawOpenWith: unknown): Harness {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const update = vi.fn()
|
||||
act(() => {
|
||||
root.render(createElement(OpenWithSettings, {
|
||||
pluginSettings: { openWith: rawOpenWith },
|
||||
updatePluginSetting: update,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
update,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
],
|
||||
pinned: ['vscode', 'custom:a'],
|
||||
}
|
||||
|
||||
describe('OpenWithSettings', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('seeds the SSH host input from the persisted blob and commits edits', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input:not([type])')
|
||||
expect(input?.value).toBe('dev')
|
||||
// The native value setter (React's value tracker must see the change).
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
||||
act(() => {
|
||||
setter.call(input, 'prod')
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
expect(harness.update.mock.calls[0]?.[0]).toBe('openWith')
|
||||
expect(harness.update.mock.calls[0]?.[1]).toMatchObject({ sshHost: 'prod' })
|
||||
})
|
||||
|
||||
it('adds a custom editor row and commits the extended list', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const add = [...harness.container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent?.trim() === 'Add')
|
||||
expect(add).toBeDefined()
|
||||
act(() => { add!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(2)
|
||||
expect(blob.pinned).toEqual(['vscode', 'custom:a'])
|
||||
})
|
||||
|
||||
it('removes an editor and prunes its pinned id', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const remove = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')
|
||||
expect(remove).toBeDefined()
|
||||
act(() => { remove!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(0)
|
||||
expect(blob.pinned).toEqual(['vscode'])
|
||||
})
|
||||
|
||||
it('shows the validity hint when a row is incomplete', () => {
|
||||
harness = mountSettings({
|
||||
sshHost: '',
|
||||
customEditors: [{ id: 'x', name: 'Bad', urlTemplate: 'x://file/x', isVscodeFamily: true }],
|
||||
pinned: [],
|
||||
})
|
||||
expect(harness.container.textContent).toContain('are not shown in the menu')
|
||||
})
|
||||
|
||||
it('edits the VSCode-family flag of one row', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const checkbox = harness.container.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox?.checked).toBe(false)
|
||||
act(() => { checkbox!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: { isVscodeFamily: boolean }[] }
|
||||
expect(blob.customEditors[0]?.isVscodeFamily).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The file tree "open with" pure logic: config parsing (tolerant), target
|
||||
* resolution (built-ins + custom, SSH filtering), URL building (local file
|
||||
* URLs and VSCode-family SSH-remote URLs) and the custom-editor validators.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
normalizeUrlPath,
|
||||
openWithSshActive,
|
||||
openWithUrl,
|
||||
parseOpenWithConfig,
|
||||
resolveOpenWithTargets,
|
||||
type CustomEditor,
|
||||
type OpenWithTarget,
|
||||
} from '../src/client/open-with.ts'
|
||||
|
||||
describe('parseOpenWithConfig', () => {
|
||||
it('returns the defaults for null / non-object / array values', () => {
|
||||
expect(parseOpenWithConfig(null)).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig('junk')).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig([])).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
})
|
||||
|
||||
it('keeps structurally valid custom editors (incl. in-progress rows) and drops junk', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'b', name: '', urlTemplate: 'b://file/{path}', isVscodeFamily: true },
|
||||
{ id: 'c', name: 'Trae', urlTemplate: 'trae://file/{path}', isVscodeFamily: true },
|
||||
'junk',
|
||||
null,
|
||||
42,
|
||||
],
|
||||
pinned: ['vscode', 'custom:a', 42, '', 'custom:c'],
|
||||
})
|
||||
expect(config.sshHost).toBe('dev')
|
||||
expect(config.customEditors.map(editor => editor.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(config.pinned).toEqual(['vscode', 'custom:a', 'custom:c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOpenWithTargets', () => {
|
||||
const localCustom: CustomEditor = { id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false }
|
||||
const familyCustom: CustomEditor = { id: 'e2', name: 'Zed-like fork', urlTemplate: 'x-zed://file/{path}', isVscodeFamily: true }
|
||||
|
||||
it('lists built-ins first, then custom editors (local mode)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({ customEditors: [localCustom] }))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
expect(targets[0]?.kind).toBe('reveal')
|
||||
expect(targets[1]?.urlTemplate).toBe('vscode://file/{path}')
|
||||
})
|
||||
|
||||
it('excludes incomplete custom-editor rows from the menu (they stay in the blob)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({
|
||||
customEditors: [
|
||||
{ id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'e2', name: '', urlTemplate: 'incomplete://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
}))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
})
|
||||
|
||||
it('drops local-only targets in SSH mode (file manager, Zed, non-VSCode custom)', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev', customEditors: [localCustom, familyCustom] })
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(targets.map(target => target.id)).toEqual(['vscode', 'cursor', 'custom:e2'])
|
||||
})
|
||||
|
||||
it('prunes unknown pinned ids only when resolving (the parse keeps them)', () => {
|
||||
const config = parseOpenWithConfig({ pinned: ['ghost'] })
|
||||
expect(config.pinned).toEqual(['ghost'])
|
||||
expect(resolveOpenWithTargets(config).map(target => target.id)).toContain('vscode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWithUrl', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({}))
|
||||
const byId = (id: string): OpenWithTarget => {
|
||||
const target = targets.find(item => item.id === id)
|
||||
if (target === undefined) throw new Error(`no target ${id}`)
|
||||
return target
|
||||
}
|
||||
|
||||
it('builds local file URLs with the raw path (backslashes normalized)', () => {
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', parseOpenWithConfig({}))).toBe('vscode://file//home/u/f.ts')
|
||||
expect(openWithUrl(byId('vscode'), 'C:\\Users\\u\\f.ts', parseOpenWithConfig({}))).toBe('vscode://file/C:/Users/u/f.ts')
|
||||
expect(openWithUrl(byId('zed'), '/x/y', parseOpenWithConfig({}))).toBe('zed://file//x/y')
|
||||
})
|
||||
|
||||
it('builds SSH-remote URLs for VSCode-family targets in SSH mode', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev' })
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', config))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(openWithUrl(byId('cursor'), '/home/u/f.ts', config))
|
||||
.toBe('cursor://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
})
|
||||
|
||||
it('keeps the local URL form for non-VSCode-family targets even in SSH mode', () => {
|
||||
// (Such targets are hidden from the SSH menu; the URL builder stays honest.)
|
||||
expect(openWithUrl(byId('zed'), '/x', parseOpenWithConfig({ sshHost: 'dev' }))).toBe('zed://file//x')
|
||||
})
|
||||
|
||||
it('returns undefined for reveal targets and malformed templates', () => {
|
||||
expect(openWithUrl(byId('explorer'), '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noScheme: OpenWithTarget = { id: 'x', name: '', kind: 'url', urlTemplate: 'not-a-url {path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noScheme, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noSlash: OpenWithTarget = { id: 'y', name: '', kind: 'url', urlTemplate: 'a:file/{path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noSlash, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the custom template scheme for SSH URLs of VSCode-family custom editors', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'e', name: 'Fork', urlTemplate: 'myfork://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
})
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(openWithUrl(targets[targets.length - 1]!, '/r/f.ts', config))
|
||||
.toBe('myfork://vscode-remote/ssh-remote+dev/r/f.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalizeUrlPath converts backslashes to slashes', () => {
|
||||
expect(normalizeUrlPath('a\\b\\c')).toBe('a/b/c')
|
||||
})
|
||||
|
||||
it('openWithSshActive trims the host', () => {
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' ' }))).toBe(false)
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' dev ' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('isValidCustomEditor requires a name, a {path} placeholder and a scheme:// prefix', () => {
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/{path}' })).toBe(true)
|
||||
expect(isValidCustomEditor({ name: '', urlTemplate: 'a://file/{path}' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/x' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a:file/{path}' })).toBe(false)
|
||||
})
|
||||
|
||||
it('newCustomEditorId yields unique non-empty ids', () => {
|
||||
const first = newCustomEditorId()
|
||||
const second = newCustomEditorId()
|
||||
expect(first).not.toBe(second)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -28,8 +28,9 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import type { TabDescriptor } from '../src/client/service.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SettingsBody, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
@@ -347,3 +348,68 @@ describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () =>
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsBody rows + custom render panel (open-with seam)', () => {
|
||||
it('renders the declarative rows AND the custom panel when both are declared', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto row',
|
||||
desc: () => 'row description',
|
||||
}],
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('Auto row')
|
||||
expect(html).toContain('row description')
|
||||
expect(html).toContain('data-custom-panel')
|
||||
expect(html).toContain('custom panel')
|
||||
})
|
||||
|
||||
it('renders ONLY the custom panel when no rows are declared (unchanged behavior)', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('custom panel')
|
||||
expect(html).not.toContain('Auto row')
|
||||
})
|
||||
})
|
||||
|
||||
+14
-1
@@ -41,7 +41,7 @@
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules, createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
@@ -67,6 +67,18 @@ const CLIENT_EXTERNALS = [
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/**
|
||||
* react-icons' exports map lists `require` BEFORE `import`, so the shared
|
||||
* conditionNames resolve the unshakeable CJS entry and the whole icon set
|
||||
* lands in the core bundle (~6.4 MB extra). Pin the two sets the client
|
||||
* uses to their ESM entries, which tree-shake down to the imported icons.
|
||||
*/
|
||||
const reactIconsRoot = dirname(dirname(require.resolve('react-icons/lib')))
|
||||
const REACT_ICONS_ESM_ALIAS = {
|
||||
'react-icons/si': join(reactIconsRoot, 'si/index.mjs'),
|
||||
'react-icons/vsc': join(reactIconsRoot, 'vsc/index.mjs'),
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline (mirror of the official
|
||||
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
|
||||
@@ -140,6 +152,7 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
alias: REACT_ICONS_ESM_ALIAS,
|
||||
},
|
||||
},
|
||||
// External wins for module-table entries; every other dependency inlines.
|
||||
|
||||
Reference in New Issue
Block a user