feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树

用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
This commit is contained in:
Menghuan1918
2026-09-28 18:08:21 +08:00
parent 36fbc5ac7e
commit b011be1e98
56 changed files with 4468 additions and 194 deletions
+60
View File
@@ -34,6 +34,8 @@ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './edit
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import { openSidebarFile } from './sidebar-file.ts'
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
import { updatePluginSettings } from './plugin-settings.ts'
import { createOpenInApp } from './open-in-app.ts'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
@@ -55,6 +57,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)
@@ -139,6 +145,16 @@ export function EditorHost(props: {
// target list, a URL vocabulary or a spawn route — the host reports which
// applications are actually installed for THIS path.
const openInApp = useMemo(() => createOpenInApp(ctx), [ctx])
// 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). A
// folder tab is a folder window in BOTH modes: the tree rooted at the
@@ -185,6 +201,40 @@ 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 to its opener — local `file` URLs go
* to the host's external opener, while the SSH-remote form for
* VSCode-family editors launches on the browser/client machine (see
* api.openExternal). Failures are logged only — a missing handler is the
* OS's/browser'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 } }
})
}
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
// panel, free windows): a rename retargets its tab to the new path; a
// delete closes tabs at or under the removed path. See tree-mutations.ts.
@@ -355,6 +405,11 @@ export function EditorHost(props: {
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openInApp={openInApp}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
@@ -477,6 +532,11 @@ export function EditorHost(props: {
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openInApp={openInApp}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
+256 -33
View File
@@ -42,16 +42,21 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconDownloadOutlineRegular,
IconArchiveOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular,
IconDownloadOutlineRegular,
IconEditOutlineRegular, IconFolderOpenRegular, IconLinkOutlineRegular, IconPlusOutlineRegular, IconTrashOutlineRegular,
Menu, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, archiveUrl, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import type { OpenInApp, OpenInAppEntry } from './open-in-app.ts'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import type { BetterSidebarService } from './service.ts'
@@ -344,9 +349,23 @@ export function FileTree(props: {
/**
* The host's open-in-app handle (EditorHost builds it and injects it).
* Absent, or reporting the host cannot hand paths to the desktop, hides the
* whole "open in app" section.
* HOST half of the "打开方式" section (the plugin's own targets stay).
*/
openInApp?: OpenInApp
/**
* The PLUGIN's own "open with" menu: resolved external targets (already
* SSH-filtered and in menu order). Absent → no plugin half. Coexists with
* {@link openInApp} — the section lists both.
*/
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 plugin target externally (reveal or URL — the caller decides). */
onOpenWith?: (targetId: string, path: string) => void
/** Toggle one plugin target's pinned state (the submenu row's pushpin). */
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft (file vs directory). */
onReferenceFile: (path: string, isDir: boolean) => void
/** A rename landed (old row path → new path): the caller retargets open tabs. */
@@ -382,7 +401,8 @@ export function FileTree(props: {
// callbacks must not change identity when the caller re-renders).
const {
sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
openInApp, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
} = props
/** The live props for the stable callbacks below (identity churns per render). */
const propsRef = useRef(props)
@@ -414,6 +434,9 @@ export function FileTree(props: {
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
// The plugin submenu can tower past the viewport; publish its flip geometry
// for layout.css while the row menu is open.
useSubmenuFlip(rowMenu)
/** The open-in-app rows for the open menu (null entries = listing failed). */
const [apps, setApps] = useState<{ path: string; entries: readonly OpenInAppEntry[] | null } | null>(null)
/** The row being renamed inline: its path plus the edit buffer. */
@@ -892,12 +915,6 @@ export function FileTree(props: {
setActionError(t('openInAppFailed', { path }))
}, [])
/**
* The open menu's app rows are fetched while the menu is open: the host
* resolves a file's registered applications (or a directory's application
* catalogue). Availability is re-read per open — a host that cannot hand
* paths to the desktop hides the section entirely.
*/
/**
* Whether the host can hand paths to a native desktop — probed ONCE per
* handle, on mount. The adapter starts in its `null` (unprobed) state, and
@@ -928,6 +945,12 @@ export function FileTree(props: {
return () => { cancelled = true }
}, [openInApp])
/**
* The open menu's app rows are fetched while the menu is open: the host
* resolves a file's registered applications (or a directory's application
* catalogue). A host that cannot hand paths to a desktop keeps `appReady`
* false, so no listing is attempted.
*/
useEffect(() => {
if (rowMenu === null || openInApp === undefined || !appReady) {
setApps(null)
@@ -962,22 +985,110 @@ export function FileTree(props: {
void handle.reveal(path).then((ok) => { if (!ok) reportOpenFailure(path) }).catch(() => { reportOpenFailure(path) })
}, [reportOpenFailure])
/** The menu label of one plugin 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 menu's "open in app" section for one row: a heading label plus the
* host's rows (the default-application row first for files), a separator,
* and reveal-in-file-manager. Absent when the caller wired no handle or the
* host cannot open desktop paths; an empty/failed listing degrades to one
* disabled line so the section never silently disappears mid-interaction.
* The PLUGIN half of the "open with" section: the pinned targets as DIRECT
* rows, then the parent row with every target as a nested submenu (main's
* shape — pins, chevron, SSH suffixes). The section only renders when the
* caller wired the feature and at least one target is left.
*
* `hostReady` filters out the built-in `explorer` target: the host's own
* reveal row replaces it, and two identical "File Manager" rows would be
* noise. When the host is NOT ready the target stays, so reveal is never
* lost.
*/
const openInAppEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
if (openInApp === undefined || !appReady) return []
const head: MenuEntry = { type: 'label', id: 'open-in-app-label', text: t('openInApp') }
const entries = apps !== null && apps.path === target.path ? apps.entries : undefined
if (entries === undefined) return [head, { id: 'open-in-app-loading', label: t('loading'), disabled: true }]
if (entries === null || entries.length === 0) {
return [head, { id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }]
const openWithEntries = (hostReady: boolean): MenuEntry[] => {
if (openWithTargets === undefined || onOpenWith === undefined) return []
const targets = hostReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets
if (targets.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.
* The compact menu's icon slot is 14px, so every mark renders at 14. */
const itemIcon = (target: OpenWithTarget): ReactNode => {
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutlineRegular size={14} />
}
const rows: MenuItem[] = []
const pinned = targets
.filter(target => pinnedIds.includes(target.id))
.map<MenuItem>(target => ({
id: `open-with:${target.id}`,
label: openWithLabelOf(target),
icon: itemIcon(target),
}))
const submenu = targets.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={12} /> : <VscPin size={12} />}
</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>
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={14} />,
submenu,
},
]
}
/**
* The HOST half of the section: the default-application row (files, when the
* OS reports one) followed by every registered handler. A pending listing
* shows one disabled line, an empty/failed listing the disabled
* `openInAppEmpty` line — the reveal row below still renders either way.
*/
const hostAppEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
const entries = apps !== null && apps.path === target.path ? apps.entries : undefined
if (entries === undefined) return [{ id: 'open-in-app-loading', label: t('loading'), disabled: true }]
if (entries === null || entries.length === 0) {
return [{ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true }]
}
const rows: MenuEntry[] = []
const fallback = entries.find(entry => entry.isDefault)
if (!target.isDir && fallback !== undefined) {
rows.push({ id: 'open-in-app:default', label: t('openInAppDefault'), icon: <AppGlyph entry={fallback} /> })
@@ -986,12 +1097,60 @@ export function FileTree(props: {
if (!target.isDir && entry.isDefault) continue
rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: <AppGlyph entry={entry} /> })
}
return [
head,
...rows,
{ id: 'open-in-app-sep', type: 'separator' },
{ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: <IconFolderOpenRegular size={14} /> },
]
return rows
}
/**
* The menu's "打开方式" section, where the host's capability and the plugin's
* own targets COEXIST (the user asked for both). No heading row: the host
* group is self-describing (application names + "用默认应用打开"), and
* `openInApp` / `openWithMenu` read as near-duplicates in every dictionary —
* two synonyms stacked in one menu read as a repeated item. The frozen order:
* host rows → separator → plugin rows (pinned direct + submenu) →
* separator → host reveal.
* Separators only appear between two non-empty groups. The host rows (and
* reveal) vanish entirely when no handle was injected or the host cannot
* hand paths to a desktop; the plugin rows then stand alone, which is what
* keeps a remote/SSH session usable. The plugin's `explorer` target is
* dropped only while the host is ready (see {@link openWithEntries}).
*/
const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => {
const plugin = openWithEntries(appReady)
if (!appReady && plugin.length === 0) return []
const entries: MenuEntry[] = []
const separate = (): void => {
if (entries.length > 0) entries.push({ id: `open-with-group-${entries.length}`, type: 'separator' })
}
if (appReady) entries.push(...hostAppEntries(target))
if (plugin.length > 0) {
separate()
entries.push(...plugin)
}
if (appReady) {
separate()
entries.push({ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: <IconFolderOpenRegular size={14} /> })
}
return entries
}
/**
* The selection's ZIP row: two or more selected rows archive together, and a
* LONE directory archives its own subtree. A lone FILE is skipped — it would
* only duplicate the plain download row.
*/
const zipEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
const count = selected.size
if (count >= 2) {
return [{
id: 'archive-selection',
label: t('zipDownloadCount', { count }),
icon: <IconArchiveOutlineRegular size={14} />,
}]
}
if (count === 1 && target.isDir) {
return [{ id: 'archive-selection', label: t('zipDownload'), icon: <IconArchiveOutlineRegular size={14} /> }]
}
return []
}
/** Download a file through the host route (raw bytes, binary-safe). */
@@ -1005,6 +1164,59 @@ export function FileTree(props: {
anchor.remove()
}
/**
* Download one archive of `paths` through the host route. Unlike the
* single-file download — a bare anchor whose failures belong to the browser
* — the archive route answers the plugin's JSON error envelope for its caps
* and refusals, and an anchor would happily save that envelope as a broken
* `.zip`. So the bytes are fetched explicitly: a non-2xx answer is parsed for
* its `{error: {message}}` envelope (the shape `api.ts` reads) and reported
* through `zipFailed`; a 2xx body becomes an object URL handed to the same
* hidden-anchor mechanics. `archiveBusyRef` keeps a double click from
* packaging the same selection twice.
*/
const archiveBusyRef = useRef(false)
const downloadArchive = (paths: readonly string[]): void => {
if (archiveBusyRef.current) return
const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip'
let url: string
try {
url = archiveUrl({ sessionId, cwd }, paths, name)
} catch (error: unknown) {
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
return
}
archiveBusyRef.current = true
void fetch(url)
.then(async (response) => {
if (!response.ok) {
const envelope: { error?: { message?: string } } | null = await response.json().catch(() => null)
throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
}
return await response.blob()
})
.then((blob) => {
setActionError(null)
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = name
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
// Revoke on the next task, not synchronously: some engines have not
// committed the download when click() returns, and revoking the URL
// under them loses the file. One tick is enough and keeps the blob
// from outliving the download.
window.setTimeout(() => { URL.revokeObjectURL(objectUrl) }, 0)
})
.catch((error: unknown) => {
setActionError(t('zipFailed', { message: error instanceof Error ? error.message : String(error) }))
})
.finally(() => { archiveBusyRef.current = false })
}
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
@@ -1357,7 +1569,7 @@ export function FileTree(props: {
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpenRegular size={14} /> }]
: []),
...(rowMenu === null ? [] : openInAppEntries(rowMenu)),
...(rowMenu === null ? [] : openWithSection(rowMenu)),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutlineRegular size={14} /> }]
@@ -1370,6 +1582,8 @@ export function FileTree(props: {
...(rowMenu?.isDir === true
? [{ id: 'new-folder', label: t('newFolder'), icon: <IconPlusOutlineRegular size={14} /> }]
: []),
// ZIP of the current selection (≥2 rows, or one lone directory).
...(rowMenu === null ? [] : zipEntries(rowMenu)),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineRegular size={14} /> },
// Explorer mutations close the menu; the workspace ROOT row is the
@@ -1402,10 +1616,20 @@ export function FileTree(props: {
openWithApp(target.path, id.slice('open-in-app:'.length))
return
}
// The plugin's own targets share one id space (pinned rows and
// submenu children alike), so the caller gets the target id + path.
if (id.startsWith('open-with:')) {
onOpenWith?.(id.slice('open-with:'.length), target.path)
return
}
if (id === 'reveal-in-file-manager') {
revealPath(target.path)
return
}
if (id === 'archive-selection') {
downloadArchive([...selected])
return
}
if (id === 'download') {
downloadFile(target.path)
return
@@ -1418,8 +1642,7 @@ export function FileTree(props: {
if (id === 'new-folder') {
startNewFolder(target.path)
return
}
if (id === 'rename') {
} if (id === 'rename') {
setRenaming({ path: target.path, value: baseName(target.path) })
return
}
+16 -2
View File
@@ -27,6 +27,7 @@ import type { SidebarStore } from './state.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenInApp } from './open-in-app.ts'
import type { OpenWithTarget } from './open-with.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import { IconButton } from './ui/index.ts'
@@ -61,8 +62,15 @@ export function TreePanel(props: {
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The host's open-in-app handle (passed through to FileTree; absent →
* the whole section is hidden). */
* the HOST half of the "打开方式" section is hidden). */
openInApp?: OpenInApp
/** The plugin's own open-with targets (passed through to FileTree; coexists
* with `openInApp`; absent → no plugin half). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
/** A tree rename landed (passed through to FileTree for tab retargeting). */
onPathRenamed?: (oldPath: string, newPath: string) => void
@@ -79,7 +87,8 @@ export function TreePanel(props: {
}) {
const {
sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
openInApp, onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
} = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
@@ -281,6 +290,11 @@ export function TreePanel(props: {
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openInApp={openInApp}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
+61
View File
@@ -239,6 +239,51 @@ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Re
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
}
/** One external-open request from the file tree. */
type OpenExternalPayload =
| { action: 'reveal'; path: string }
| { action: 'url'; url: string }
/** The host route's success shape. */
type OpenExternalResult = { started: boolean }
/**
* Remote VSCode-family URLs must be consumed on the browser/client machine:
* the DSH host can be a headless remote server with no editor or DISPLAY.
* Local editor URLs and reveal actions still belong to the host opener.
*/
function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } {
if (payload.action !== 'url') return false
let parsed: URL
try {
parsed = new URL(payload.url)
} catch {
return false
}
return parsed.protocol !== 'http:'
&& parsed.protocol !== 'https:'
&& parsed.hostname === 'vscode-remote'
&& parsed.pathname.startsWith('/ssh-remote+')
}
/**
* Dispatch an external-open request to the correct machine. SSH remote-editor
* URLs stay in the synchronous user-click chain and navigate the client so
* its registered vscode:// / cursor:// handler can launch. Everything else
* keeps using the DSH host route.
*/
function openExternal(payload: OpenExternalPayload): Promise<OpenExternalResult> {
if (!shouldOpenExternalOnClient(payload)) {
return call<OpenExternalResult>('open.external', payload)
}
try {
window.location.assign(payload.url)
return Promise.resolve({ started: true })
} catch (error) {
return Promise.reject(error)
}
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
@@ -377,6 +422,10 @@ export const api = {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** External open for the file tree's "open with" menu. Remote SSH editor
* URLs are launched on the browser/client machine; reveal and local URLs
* keep using the host's platform opener. */
openExternal,
}
/** Absolute URL of the media route for one path (images only). */
@@ -390,6 +439,18 @@ export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/**
* Absolute URL of the archive route: one ZIP of every `paths` entry (files
* and directories, recursive) served as an attachment. Same GET shape as
* {@link downloadUrl}; the repeated `path` parameter is the only difference.
*/
export function archiveUrl(scope: SessionScope, paths: readonly string[], name: string): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, name })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
for (const path of paths) params.append('path', path)
return `/sidebar/archive?${params.toString()}`
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
+7 -3
View File
@@ -19,6 +19,7 @@ import {
import { t } from '../locales.ts'
import { openSidebarFile } from '../sidebar-file.ts'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { ChangesTab, opCountOf } from '../changes/ChangesTab.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
@@ -44,9 +45,9 @@ export function builtinTabs(): readonly TabDescriptor[] {
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page, followed by the
// workspace fence switch (the host's containment guard over every
// sidebar filesystem route). The old "open with" configuration panel
// (SSH host + custom editors) is gone: external opening is the host's
// own open-in-app capability now, with nothing to configure here.
// sidebar filesystem route); 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',
@@ -72,6 +73,9 @@ export function builtinTabs(): readonly TabDescriptor[] {
title: () => t('settingsFenceTitle'),
desc: () => t('settingsFenceDesc'),
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
),
},
component: ({ ctx, store, scope, tab, visible, expanded, revealed, onToggleDir, onReferenceFile }) => (
<EditorHost
+1
View File
@@ -225,6 +225,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
{lens === 'git'
? (
<GitLens
ctx={ctx}
scope={scope}
store={store}
visible={visible}
+206 -88
View File
@@ -1,11 +1,18 @@
/**
* The Git lens of the changes tab: repository truth — the changed files
* (unstaged / staged), stage/unstage, commit with a message box, branch and
* checkout switching, and a VSCode-like history with decorations, author and
* relative time. Clicking a changed file or a history row previews it in the
* tab's shared bottom pane (see {@link DiffPane}); rows carry right-click
* context menus with advanced operations (open in editor, discard, revert,
* cherry-pick, copy paths/hashes).
* (unstaged / staged) as a directory tree, stage/unstage, commit with a
* message box, branch and checkout switching, and a VSCode-like history with
* decorations, author and relative time. Clicking a changed file or a history
* row previews it in the tab's shared bottom pane (see {@link DiffPane}); rows
* carry right-click context menus with advanced operations (open in editor,
* discard, revert, cherry-pick, copy paths/hashes).
*
* The change list is a TREE, not a flat list: {@link buildChangeTree} folds
* each group's paths into directories (single-child chains compressed), so a
* nested change reads as structure instead of as a wall of rows. Directory
* rows disclose their subtree (`aria-expanded`); file rows carry the host's
* own file artwork, the porcelain letter and the trailing stage action, which
* only shows on hover/focus to keep the row quiet.
*
* The git status itself is NOT fetched here: it comes from the plugin's one
* shared store (`ui/git-status.ts`), which the file tree also subscribes to,
@@ -19,13 +26,15 @@
* failures used to land under the commit box, which read as "your commit
* failed" for an action the user never ran.
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconCodeOutlineRegular, IconCopyOutlineRegular, IconPlusOutlineRegular,
Button, IconChevronRightOutlineRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconPlusOutlineRegular,
IconTrashOutlineRegular, Input, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { builtinFileIcon, builtinFolderIcon } from '../file-icons.tsx'
import { usePolling } from '../use-polling.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
@@ -35,6 +44,7 @@ import {
ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge, invalidateGitStatus, statusOfXY,
useGitStatus, type GitFileStatus, type GitTone, type StatusTone,
} from '../ui/index.ts'
import { buildChangeTree, type ChangeDir, type ChangeFile, type ChangeNode } from './change-tree.ts'
import css from './changes.module.css'
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
@@ -49,9 +59,10 @@ function isUnstagedEntry(entry: GitStatusEntry): boolean {
return statusOfXY(entry.xy)?.unstaged === true
}
/** Whether the entry is untracked (`??`): git diff never includes it. */
function isUntracked(entry: GitStatusEntry): boolean {
return entry.xy === '??'
/** Whether a row is untracked (`??`): git diff never includes it, so the
* preview and the discard action must know. */
function isUntrackedStatus(status: GitFileStatus): boolean {
return status.tone === 'untracked'
}
/** The porcelain tone → the kit badge tone (a copy reads as a rename). */
@@ -65,13 +76,6 @@ const BADGE_TONE: Record<GitTone, StatusTone> = {
conflict: 'conflict',
}
/** Split a repo-relative path into the row's name (kept) and its directory
* (dimmed context). */
function splitPath(path: string): { name: string; dir: string } {
const at = path.lastIndexOf('/')
return at === -1 ? { name: path, dir: '' } : { name: path.slice(at + 1), dir: path.slice(0, at) }
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
@@ -98,6 +102,15 @@ interface ConfirmState {
onConfirm: () => Promise<unknown>
}
/** One open file-row context menu (cursor position for the portaled Menu). */
interface FileMenuState {
path: string
staged: boolean
untracked: boolean
x: number
y: number
}
/** History batch size: the log loads lazily in pages so a long history never
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
@@ -108,11 +121,15 @@ const LOG_BATCH = 20
* within ~30s, without a second git process per status tick. */
const WORKTREE_POLL_MS = 30_000
/** One changed file's row: badge letter + file name + dimmed directory +
* the trailing stage/unstage action. The ONE renderer both sections use. */
function ChangeRow(props: {
entry: GitStatusEntry
status: GitFileStatus | undefined
/** A stable empty entry list: the tree memo must not rebuild on every render
* just because `snapshot?.entries ?? []` minted a new array. */
const NO_ENTRIES: readonly GitStatusEntry[] = []
/** One changed file's row: the host's file artwork + porcelain letter + name
* + the trailing stage action (revealed on hover/focus). */
function FileRow(props: {
node: ChangeFile
icon: ReactNode
staged: boolean
busy: boolean
selected: boolean
@@ -120,27 +137,25 @@ function ChangeRow(props: {
onContextMenu(event: MouseEvent): void
onToggleStage(): void
}): ReactNode {
const { entry, status, staged, busy, selected } = props
const { name, dir } = splitPath(entry.path)
const { node, staged, busy, selected } = props
const action = staged ? t('unstage') : t('stage')
return (
<div className={css.row} data-selected={selected ? 'true' : undefined}>
<button
type="button"
className={css.rowMain}
data-path={entry.path}
title={entry.path}
data-path={node.path}
title={node.path}
onClick={props.onPreview}
onContextMenu={props.onContextMenu}
>
<StatusBadge tone={status === undefined ? 'neutral' : BADGE_TONE[status.tone]}>
{status?.letter ?? '?'}
</StatusBadge>
<span className={css.rowName}>{name}</span>
{dir !== '' && <span className={css.rowDir}>{dir}</span>}
<span className={css.rowIcon} aria-hidden="true">{props.icon}</span>
<StatusBadge tone={BADGE_TONE[node.status.tone]} title={node.path}>{node.status.letter}</StatusBadge>
<span className={css.rowName}>{node.name}</span>
</button>
<IconButton
size="sm"
className={css.rowAction}
disabled={busy}
label={action}
icon={staged ? <IconTrashOutlineRegular size={14} /> : <IconPlusOutlineRegular size={14} />}
@@ -150,6 +165,29 @@ function ChangeRow(props: {
)
}
/** One directory row: the disclosure chevron + folder artwork + the label
* (a compressed chain reads as one 'a/b/c' line) + how much changed below. */
function DirRow(props: { node: ChangeDir; icon: ReactNode; open: boolean; onToggle(): void }): ReactNode {
const { node, open } = props
return (
<button
type="button"
className={css.dirRow}
data-dir={node.path}
aria-expanded={open}
title={node.path}
onClick={props.onToggle}
>
<span className={css.chevron} data-open={open ? 'true' : undefined} aria-hidden="true">
<IconChevronRightOutlineRegular size={12} />
</span>
<span className={css.rowIcon} aria-hidden="true">{props.icon}</span>
<span className={css.dirName}>{node.name}</span>
<span className={css.countPill}>{node.changes}</span>
</button>
)
}
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
@@ -163,10 +201,23 @@ export interface GitLensProps {
visible: boolean
/** Bumped by the tab header's refresh action (0 = never asked). */
refreshTick?: number
/** The tab's context, for the registered file/folder icon chain (absent →
* the host's built-in artwork, exactly like the file tree falls back). */
ctx?: Context
}
export function GitLens(props: GitLensProps) {
const { scope, store, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0 } = props
const { scope, store, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0, ctx } = props
const service = ctx?.get('betterSidebar')
/** The file row's artwork: a registered icon wins, else the host's own
* (the same chain the file tree runs, so both pages agree). */
const fileGlyph = useCallback((path: string): ReactNode => (
service === undefined ? builtinFileIcon(path, 14) : service.fileIcon(path, 14)
), [service])
const folderGlyph = useCallback((path: string, open: boolean): ReactNode => (
service === undefined ? builtinFolderIcon(open, 14) : service.folderIcon(path, open, 14)
), [service])
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
@@ -191,11 +242,15 @@ export function GitLens(props: GitLensProps) {
const [viewError, setViewError] = useState<string | null>(null)
/** The open file-row context menu (cursor position for the portaled Menu). */
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
const [fileMenu, setFileMenu] = useState<FileMenuState | null>(null)
/** The open history-row context menu. */
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
/** The pending destructive action awaiting confirmation. */
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
/** Collapsed directory rows, keyed per GROUP + path: a directory that has
* changes on both sides folds independently in each section (and the two
* groups never fight over one key). Default: everything expanded. */
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>())
/** The selected checkout, readable synchronously by the refresh chain
* without making every callback depend on the state it writes. */
@@ -418,9 +473,9 @@ export function GitLens(props: GitLensProps) {
const stageError = (reason: unknown): string => t('changesStageFailed', { message: errorMessage(reason) })
const stageEntry = (entry: GitStatusEntry, staged: boolean): void => {
const stageEntry = (path: string, staged: boolean): void => {
void runAction(
() => (staged ? api.gitUnstage(gitScopeNow(), entry.path, selectedWorktree) : api.gitStage(gitScopeNow(), entry.path, selectedWorktree)),
() => (staged ? api.gitUnstage(gitScopeNow(), path, selectedWorktree) : api.gitStage(gitScopeNow(), path, selectedWorktree)),
stageError,
)
}
@@ -520,10 +575,10 @@ export function GitLens(props: GitLensProps) {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
const openFileMenu = (event: MouseEvent, path: string, staged: boolean, untracked: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
setFileMenu({ path, staged, untracked, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
@@ -533,11 +588,11 @@ export function GitLens(props: GitLensProps) {
}
/** The preview ref for one changed file (one ref per path+side). */
const worktreeRefOf = (entry: GitStatusEntry, staged: boolean): SidebarDiffRef => ({
const worktreeRefOf = (path: string, untracked: boolean, staged: boolean): SidebarDiffRef => ({
kind: 'worktree',
path: entry.path,
path,
staged,
untracked: isUntracked(entry),
untracked,
worktree: selectedWorktree,
repoRoot,
})
@@ -553,35 +608,78 @@ export function GitLens(props: GitLensProps) {
})
/** Whether a worktree row is the one currently previewed. */
const isPreviewedWorktree = (entry: GitStatusEntry, staged: boolean): boolean => {
const isPreviewedWorktree = (path: string, staged: boolean): boolean => {
if (selectedRef === null || selectedRef.kind !== 'worktree') return false
return selectedRef.path === entry.path && selectedRef.staged === staged
return selectedRef.path === path && selectedRef.staged === staged
&& (selectedRef.worktree ?? '') === (selectedWorktree ?? '')
}
const entries = snapshot?.entries ?? []
const stagedEntries = entries.filter(isStagedEntry)
const unstagedEntries = entries.filter(isUnstagedEntry)
const entries = snapshot?.entries ?? NO_ENTRIES
// One fold per group, memoized on the snapshot's own entry array: the tree
// (compression + sorting + counts) is rebuilt only when git answers anew.
const { stagedEntries, unstagedEntries, stagedTree, unstagedTree } = useMemo(() => {
const staged = entries.filter(isStagedEntry)
const unstaged = entries.filter(isUnstagedEntry)
return {
stagedEntries: staged,
unstagedEntries: unstaged,
stagedTree: buildChangeTree(staged),
unstagedTree: buildChangeTree(unstaged),
}
}, [entries])
const isRepo = snapshot?.isRepo === true
const branch = snapshot?.branch ?? ''
const branchOptions = branch === '' ? branchNames : [branch, ...branchNames.filter(name => name !== branch)]
const clean = isRepo && stagedEntries.length === 0 && unstagedEntries.length === 0
/** One change row (the single renderer of both sections). */
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => (
<ChangeRow
key={`${staged ? 's' : 'u'}:${entry.path}`}
entry={entry}
status={statusOfXY(entry.xy)}
/** Fold or unfold one directory row (keyed per group, see `collapsed`). */
const toggleDir = (key: string): void => {
setCollapsed((current) => {
const next = new Set(current)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
/** One file row of a group's tree (the single file-row renderer). */
const renderFile = (node: ChangeFile, staged: boolean): ReactNode => (
<FileRow
key={`${staged ? 's' : 'u'}:${node.path}`}
node={node}
icon={fileGlyph(node.path)}
staged={staged}
busy={busy}
selected={isPreviewedWorktree(entry, staged)}
onPreview={() => { onPreview(worktreeRefOf(entry, staged)) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
onToggleStage={() => { stageEntry(entry, staged) }}
selected={isPreviewedWorktree(node.path, staged)}
onPreview={() => { onPreview(worktreeRefOf(node.path, isUntrackedStatus(node.status), staged)) }}
onContextMenu={(event) => { openFileMenu(event, node.path, staged, isUntrackedStatus(node.status)) }}
onToggleStage={() => { stageEntry(node.path, staged) }}
/>
)
/** One directory row plus its (default-expanded) subtree. */
const renderDir = (node: ChangeDir, staged: boolean): ReactNode => {
const key = `${staged ? 's' : 'u'}:${node.path}`
const open = !collapsed.has(key)
return (
<div key={key}>
<DirRow node={node} icon={folderGlyph(node.path, open)} open={open} onToggle={() => { toggleDir(key) }} />
{open && (
<div className={css.treeChildren}>
{node.children.map(child => (child.kind === 'dir' ? renderDir(child, staged) : renderFile(child, staged)))}
</div>
)}
</div>
)
}
/** One group's whole tree. */
const renderTree = (nodes: readonly ChangeNode[], staged: boolean): ReactNode => (
<div className={css.tree}>
{nodes.map(node => (node.kind === 'dir' ? renderDir(node, staged) : renderFile(node, staged)))}
</div>
)
return (
<div className={css.git}>
{(repoChoices.length > 1 || worktrees.length > 1 || branch !== '') && (
@@ -639,30 +737,50 @@ export function GitLens(props: GitLensProps) {
{isRepo && !clean && (
<>
<SectionHeader
label={t('unstaged')}
count={unstagedEntries.length}
action={unstagedEntries.length > 0
? (
<button type="button" className={css.link} disabled={busy} onClick={() => { stageAll(false) }}>
{t('stageAll')}
</button>
)
: undefined}
/>
{unstagedEntries.map(entry => renderEntry(entry, false))}
<SectionHeader
label={t('staged')}
count={stagedEntries.length}
action={stagedEntries.length > 0
? (
<button type="button" className={css.link} disabled={busy} onClick={() => { stageAll(true) }}>
{t('unstageAll')}
</button>
)
: undefined}
/>
{stagedEntries.map(entry => renderEntry(entry, true))}
{/* Each group is its own block so its sticky header is released (and
replaced) exactly when the group scrolls away. */}
<div className={css.group} data-group="unstaged">
<SectionHeader
className={css.groupHeader}
label={t('unstaged')}
action={unstagedEntries.length > 0
? (
<IconButton
className={css.headerAction}
size="sm"
disabled={busy}
label={t('stageAll')}
icon={<IconPlusOutlineRegular size={14} />}
onClick={() => { stageAll(false) }}
/>
)
: undefined}
>
<span className={css.countPill} data-count={unstagedEntries.length}>{unstagedEntries.length}</span>
</SectionHeader>
{renderTree(unstagedTree, false)}
</div>
<div className={css.group} data-group="staged">
<SectionHeader
className={css.groupHeader}
label={t('staged')}
action={stagedEntries.length > 0
? (
<IconButton
className={css.headerAction}
size="sm"
disabled={busy}
label={t('unstageAll')}
icon={<IconTrashOutlineRegular size={14} />}
onClick={() => { stageAll(true) }}
/>
)
: undefined}
>
<span className={css.countPill} data-count={stagedEntries.length}>{stagedEntries.length}</span>
</SectionHeader>
{renderTree(stagedTree, true)}
</div>
</>
)}
@@ -753,13 +871,13 @@ export function GitLens(props: GitLensProps) {
// action for that checkout so the menu does not offer a no-op
// that confuses the user; with the fence disarmed (the
// `workspaceFence` pref) the open is allowed through.
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)))
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.path)))
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutlineRegular size={14} /> }]
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutlineRegular size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconPlusOutlineRegular size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
...(fileMenu !== null && !fileMenu.untracked
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutlineRegular size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
@@ -771,7 +889,7 @@ export function GitLens(props: GitLensProps) {
if (target === null) return
setFileMenu(null)
if (id === 'open') {
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.path)
// Defense-in-depth: the menu hides this action when the
// resolved path escapes the session workspace, but a
// racing repo switch could still reach here with a path
@@ -782,23 +900,23 @@ export function GitLens(props: GitLensProps) {
return
}
if (id === 'stage') {
stageEntry(target.entry, target.staged)
stageEntry(target.path, target.staged)
return
}
if (id === 'discard') {
runConfirmed({
title: t('discardTitle'),
description: t('discardDesc', { path: target.entry.path }),
description: t('discardDesc', { path: target.path }),
confirmLabel: t('discard'),
onConfirm: () => api.gitDiscard(gitScopeNow(), target.entry.path, selectedWorktree),
onConfirm: () => api.gitDiscard(gitScopeNow(), target.path, selectedWorktree),
})
return
}
if (id === 'relative') {
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.path))
return
}
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.path))
}}
portal
compact
+124
View File
@@ -0,0 +1,124 @@
/**
* The changes list's hierarchy.
*
* A `git status` answer is a flat list of repo-relative paths, and the Git
* lens used to render exactly that: one row per file, with the file name and
* its directory squeezed onto the same line. Nested work (a refactor touching
* `src/client/changes/*`) then read as a wall of near-identical rows. This
* module folds the list into the directory tree the lens renders — VS Code's
* SCM tree, including its single-child directory compression — so the shape
* of the change is legible at a glance.
*
* Pure — no React, no DOM. `buildChangeTree` runs once per group per fold;
* the renderer only walks the result.
*/
import type { GitStatusEntry } from '../api.ts'
import { statusOfXY, type GitFileStatus } from '../ui/index.ts'
/** One changed file (a leaf), carrying its porcelain status. */
export interface ChangeFile {
kind: 'file'
/** The file's own name (the last path segment). */
name: string
/** The repo-relative path exactly as git reported it (the row's identity). */
path: string
status: GitFileStatus
}
/** One directory row: its children plus the number of changed files beneath. */
export interface ChangeDir {
kind: 'dir'
/** The display label: one segment, or the compressed chain ('src/client'). */
name: string
/** The deepest directory this row stands for. */
path: string
children: ChangeNode[]
/** Changed files anywhere under this row (the row's count pill). */
changes: number
}
export type ChangeNode = ChangeDir | ChangeFile
/** A directory being assembled (children keyed by their own segment). */
interface DirDraft {
name: string
path: string
dirs: Map<string, DirDraft>
files: ChangeFile[]
}
/** Directories before files, then by name — case-insensitively, with the raw
* name breaking ties so the order never depends on the locale's collation. */
function compareNodes(left: ChangeNode, right: ChangeNode): number {
if (left.kind !== right.kind) return left.kind === 'dir' ? -1 : 1
const folded = left.name.toLowerCase().localeCompare(right.name.toLowerCase())
return folded !== 0 ? folded : left.name.localeCompare(right.name)
}
/** The number of changed files under a finished child list. */
function countFiles(nodes: readonly ChangeNode[]): number {
let count = 0
for (const node of nodes) count += node.kind === 'dir' ? node.changes : 1
return count
}
/** Materialize one draft directory, compressing its single-child chain. */
function finishDir(draft: DirDraft): ChangeDir {
let name = draft.name
let current = draft
// A directory with no file of its own and exactly one subdirectory is not
// worth a row: the whole chain reads as one label (`a/b/c`). Compression
// stops the moment the path branches or a file sits at this level, so a row
// always stands for a place where something actually changed.
while (current.files.length === 0 && current.dirs.size === 1) {
const only = [...current.dirs.values()][0]!
name = `${name}/${only.name}`
current = only
}
const children = finishChildren(current)
return { kind: 'dir', name, path: current.path, children, changes: countFiles(children) }
}
/** Finish a draft's children (directories first, then its own files). */
function finishChildren(draft: DirDraft): ChangeNode[] {
const nodes: ChangeNode[] = []
for (const child of draft.dirs.values()) nodes.push(finishDir(child))
nodes.push(...draft.files)
nodes.sort(compareNodes)
return nodes
}
/**
* Fold one group's changed files (unstaged or staged) into a directory tree.
* Rows without a porcelain status (clean, ignored) are skipped, a repeated
* path counts once, and an empty input yields an empty list.
* @param entries - the group's `git status` entries.
* @returns the tree's roots, ready to render in order.
*/
export function buildChangeTree(entries: readonly GitStatusEntry[]): ChangeNode[] {
const root: DirDraft = { name: '', path: '', dirs: new Map(), files: [] }
const seen = new Set<string>()
for (const entry of entries) {
const status = statusOfXY(entry.xy)
if (status === undefined) continue
const segments = entry.path.replace(/\\/g, '/').split('/').filter(segment => segment !== '' && segment !== '.')
if (segments.length === 0) continue
const normalized = segments.join('/')
if (seen.has(normalized)) continue
seen.add(normalized)
let at = root
for (let index = 0; index < segments.length - 1; index += 1) {
const name = segments[index]!
let next = at.dirs.get(name)
if (next === undefined) {
next = { name, path: segments.slice(0, index + 1).join('/'), dirs: new Map(), files: [] }
at.dirs.set(name, next)
}
at = next
}
// The FILE keeps git's own spelling (the lane previews, copies and menus
// all resolve this path); only the directory rows use the split form.
at.files.push({ kind: 'file', name: segments[segments.length - 1]!, path: entry.path, status })
}
return finishChildren(root)
}
+142 -28
View File
@@ -49,30 +49,125 @@
/* A single branch has nothing to choose: it reads as the row's label. */
.branchLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); }
/* The section band's text action ("stage all" / "unstage all"). */
.link { border: none; background: transparent; padding: 0; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); cursor: pointer; }
.link:hover:not(:disabled) { text-decoration: underline; }
.link:disabled { opacity: 0.4; cursor: default; }
/* ── Change groups (one directory tree each) ───────────────────────────── */
/* One changed file: badge + name + dimmed directory + the trailing action. */
/* One group is its own block so its sticky header is released exactly when the
group scrolls past (two headers in one block would stack on top of each
other). */
.group { display: flex; flex-direction: column; padding-bottom: 2px; }
/* The header band stays readable while its group scrolls under it. The extra
class keeps the opaque fill above the kit's transparent band no matter which
sheet lands first in the bundle. */
.group .groupHeader {
position: sticky;
top: 0;
z-index: 2;
background: var(--dsw-alias-bg-base);
}
/* The group's change count, as a small capsule on the label's baseline. */
.countPill {
flex: none;
min-width: 16px;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
line-height: 16px;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* A band action (stage all / unstage all) stays out of the way until the band
is hovered or holds focus — a keyboard tab reveals it too. Touch devices
have no hover, so there it is always visible. */
.group .headerAction { opacity: 0; transition: opacity 120ms var(--ds-ease-in-out); }
.group .groupHeader:hover .headerAction,
.group .groupHeader:focus-within .headerAction { opacity: 1; }
@media (hover: none) {
.group .headerAction { opacity: 1; }
}
/* The tree body: rows are flush bands, the gutter lives here. */
.tree { display: flex; flex-direction: column; padding: 0 6px; }
/* One nesting level: 12px of indent, with the guide line drawn at its edge so
the depth stays readable without a second colour. */
.treeChildren {
display: flex;
flex-direction: column;
margin-left: 11px;
border-left: 1px solid var(--dsw-alias-border-l1);
}
/* One directory row: disclosure chevron + folder artwork + label + count. */
.dirRow {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-height: 28px;
padding: 0 6px 0 2px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
cursor: pointer;
text-align: left;
}
.dirRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.dirRow:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent); outline-offset: -1px; }
.chevron { flex: none; display: inline-flex; color: var(--dsw-alias-label-tertiary); transition: transform 140ms var(--ds-ease-in-out); }
.chevron[data-open='true'] { transform: rotate(90deg); }
.dirName { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-12); }
/* One changed file: file artwork + porcelain badge + name + the trailing
action (revealed on hover/focus). */
.row {
display: flex;
align-items: center;
gap: 6px;
position: relative;
min-height: 28px;
margin: 0 6px;
padding: 0 4px 0 8px;
padding: 0 2px;
border-radius: 4px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.row:hover { background: var(--dsw-alias-interactive-bg-hover); }
.row[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* Selected and hovered are different pictures: the selected row keeps the
active fill AND the design's 2px accent marker — a pseudo element inset 4px
(the tasks page's own recipe), so the row stays shadowless and no metric
shifts between rows. */
.row[data-selected='true'],
.opRow[data-selected='true'],
.logRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.row[data-selected='true']::before,
.opRow[data-selected='true']::before,
.logRow[data-selected='true']::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
}
.rowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
align-items: center;
gap: 6px;
border: none;
background: transparent;
@@ -80,17 +175,30 @@
cursor: pointer;
text-align: left;
}
.rowMain:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent); outline-offset: -1px; }
.rowName { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
/* The directory is context, not identity: it shrinks first and stays quiet. */
.rowDir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xxxs-11); direction: rtl; text-align: left; }
/* The row's icons are content at the plugin's own scale, not chrome. */
.rowIcon { flex: none; display: inline-flex; align-items: center; }
.rowName { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-primary); }
/* The row action is invisible until its row is hovered or holds focus (a
keyboard tab lands on it and reveals it). The three-class form outranks the
kit's own disabled rule, which would otherwise pin every button to a dim but
visible 0.4 whenever a git action is in flight. */
.row:not(:hover):not(:focus-within) .rowAction { opacity: 0; }
@media (hover: none) {
.row:not(:hover):not(:focus-within) .rowAction { opacity: 1; }
}
/* ── Commit bar (docked to the bottom edge of the scroll area) ─────────── */
.commitBar {
position: sticky;
bottom: 0;
z-index: 1;
/* Above the sticky group headers: the commit bar is the surface the user
interacts with when they scroll to the bottom of the list. */
z-index: 3;
flex: none;
display: flex;
flex-direction: column;
@@ -104,26 +212,34 @@
/* ── History ───────────────────────────────────────────────────────────── */
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
/* A history row: line 1 = short-hash badge + subject, line 2 = ref pills +
author·time flushed right (the badges live on the second line so they never
crowd the subject, and the time never pushes the refs around). */
.logRow {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 12px;
gap: 3px;
padding: 6px 10px;
margin: 0 6px;
border-radius: 4px;
cursor: pointer;
}
.logRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.logRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.logLine1 { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.logHash { flex: none; font: var(--dsw-font-markdown-code-block-small); color: var(--dsw-alias-label-tertiary); }
.logHash {
flex: none;
padding: 1px 4px;
border-radius: 2px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-markdown-code-block-small);
}
.logLine2 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
.logRef { flex: none; padding: 0 5px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 2px; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); white-space: nowrap; }
.logSubject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
.logMeta { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); }
.logRef { flex: none; padding: 0 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); white-space: nowrap; }
.logSubject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-primary); }
.logMeta { margin-left: auto; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
.logMore {
display: block;
@@ -152,9 +268,8 @@
.filePath { display: block; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 6px 4px 2px; word-break: break-all; }
.fileGroup:first-child .filePath { padding-top: 2px; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; min-height: 24px; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 0 5px; cursor: pointer; text-align: left; }
.opRow { position: relative; display: flex; align-items: center; gap: 6px; width: 100%; min-height: 24px; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 0 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* A failed op carries a quiet error inset bar (the flag chip names it). */
.opRow[data-op-error='true'] { box-shadow: inset 2px 0 0 var(--dsw-alias-state-error-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind badges
@@ -197,10 +312,9 @@
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention (the kit atoms
carry their own). */
.link:focus-visible, .rowMain:focus-visible, .logRow:focus-visible,
.logMore:focus-visible, .select:focus-visible, .dragHandle:focus-visible {
/* Keyboard focus rings match the shared sidebar convention (the kit atoms and
the tree rows carry their own). */
.logRow:focus-visible, .logMore:focus-visible, .select:focus-visible, .dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
+60
View File
@@ -78,3 +78,63 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
#root [data-dsh-center-col] {
transition: none;
}
/* Context-menu submenu flip (viewport clamping for the portaled menus).
*
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
* Both defaults are the worst case for our menus: a tall "open with" submenu
* escapes past the viewport top when the row menu opens near the top, and
* past the right edge in a narrow workbench pane (a pane can be far narrower
* than the card 218 + gap + submenu 165 a submenu needs).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
* flip the growth direction; the attribute's absence restores the
* primitive's own geometry untouched, and host-owned menus are never
* affected because they never open while the attribute exists (each side
* closes on the other's outside pointerdown).
*
* No max-height/overflow scroll cap here on purpose: overflow clipping
* would cut the submenu's ::before hover bridge (the corridor that keeps
* itemWrap's mouseleave from closing the card mid-crossing). */
/* Visual tether, part 1 — the parent row stays lit while its submenu is
* open. The primitive only styles :hover, so once the pointer crossed into
* the side card the parent row's fill cleared and the floating card read as
* unrelated. The Menu marks the open parent row with aria-expanded="true"
* (host-rendered, stable), so the same hover token re-applies for the
* submenu's whole lifetime. */
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
* flip overrides below so their left/right flips still win. */
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
left: calc(100% + 4px);
}
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
left: -4px;
width: 4px;
}
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
top: -4px;
bottom: auto;
}
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
left: auto;
right: calc(100% + 4px);
}
/* The hover bridge follows the card to the left side of the parent row. */
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
left: auto;
right: -4px;
}
+23 -1
View File
@@ -58,7 +58,29 @@ export const ar: Record<string, string> = {
editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة',
openFileNewTab: 'فتح في تبويب جديد',
openFileSide: 'فتح إلى الجانب',
openInApp: 'فتح باستخدام',
zipDownload: 'ضغط وتنزيل',
zipDownloadCount: 'ضغط وتنزيل ({count} عناصر)',
zipFailed: 'فشل إنشاء الأرشيف: {message}',
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؛ في الوضع البعيد، يمكن فقط لمحرّرات عائلة VSCode فتح مسار بعيد',
openWithSettingsAdd: 'إضافة',
openWithSettingsName: 'الاسم',
openWithSettingsTemplate: 'مثل cursor://file/{path}',
openWithSettingsFamily: 'عائلة VSCode',
openWithSettingsFamilyDesc: 'يتحدّث هذا المحرّر لهجة URL لـ VSCode (يدعم الفتح عبر SSH البعيد)',
openWithSettingsRemove: 'إزالة',
openWithSettingsInvalidHint: 'لن تظهر المحرّرات التي تفتقر إلى الاسم أو القالب (يلزم أن يحتوي على {path} ويبدأ بـ scheme://) في القائمة',
openInAppDefault: 'فتح بالتطبيق الافتراضي',
openInAppEmpty: 'لا يوجد تطبيق يمكنه فتحه',
openInAppFailed: 'تعذّر الفتح: {path}',
+23 -1
View File
@@ -43,7 +43,29 @@ export const de: Record<string, string> = {
editorEmptyHint: 'Wählen Sie eine Datei aus dem Dateibaum rechts oder dem Pfadeingabefeld oben, um mit der Vorschau zu beginnen',
openFileNewTab: 'In neuem Tab öffnen',
openFileSide: 'Seitlich öffnen',
openInApp: 'Öffnen mit',
zipDownload: 'Zippen und herunterladen',
zipDownloadCount: 'Zippen und herunterladen ({count} Elemente)',
zipFailed: 'Archivierung fehlgeschlagen: {message}',
openWithMenu: 'Öffnen mit',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Im Menü anheften',
unpinOpenWith: 'Lösen',
openWithExplorer: 'Dateimanager',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH-Remote-Host',
openWithSettingsSshDesc: 'Leer = lokaler Arbeitsbereich; bei Angabe von user@host oder eines SSH-Alias verwenden die VSCode-Familienöffner das vscode-remote/ssh-remote-Protokoll, und Dateimanager / Zed / benutzerdefinierte Editoren außerhalb der VSCode-Familie werden im Menü ausgeblendet',
openWithSettingsSshPlaceholder: 'user@host oder SSH-Alias',
openWithSettingsCustomTitle: 'Benutzerdefinierte Editoren',
openWithSettingsCustomDesc: 'Name + URL-Vorlage ({path}-Platzhalter) + VSCode-Familien-Kennzeichen; im SSH-Modus können nur Editoren der VSCode-Familie Remote-Pfade öffnen',
openWithSettingsAdd: 'Hinzufügen',
openWithSettingsName: 'Name',
openWithSettingsTemplate: 'z. B. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-Familie',
openWithSettingsFamilyDesc: 'Dieser Editor spricht das VSCode-URL-Protokoll (unterstützt SSH-Remote-Öffnen)',
openWithSettingsRemove: 'Entfernen',
openWithSettingsInvalidHint: 'Editoren ohne Namen oder mit Vorlage ohne {path} / scheme:// erscheinen nicht im Menü',
openInAppDefault: 'Mit Standard-App öffnen',
openInAppEmpty: 'Keine App kann diese Datei öffnen',
openInAppFailed: 'Öffnen fehlgeschlagen: {path}',
+23 -1
View File
@@ -50,7 +50,29 @@ export const fr: Record<string, string> = {
editorEmptyHint: 'Choisissez un fichier dans l’arborescence à droite ou dans le champ de chemin ci-dessus pour commencer l’aperçu',
openFileNewTab: 'Ouvrir dans un nouvel onglet',
openFileSide: 'Ouvrir de côté',
openInApp: 'Ouvrir avec',
zipDownload: 'Compresser et télécharger',
zipDownloadCount: 'Compresser et télécharger ({count} éléments)',
zipFailed: 'Échec de l’archivage : {message}',
openWithMenu: 'Ouvrir avec',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Épingler au menu',
unpinOpenWith: 'Désépingler',
openWithExplorer: 'Explorateur de fichiers',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Hôte distant SSH',
openWithSettingsSshDesc: 'Vide = espace de travail local ; avec un user@host ou un alias SSH, les ouvreurs de la famille VSCode passent aux protocoles vscode-remote/ssh-remote, et l’explorateur de fichiers / Zed / les éditeurs personnalisés hors famille VSCode sont masqués du menu',
openWithSettingsSshPlaceholder: 'user@host ou alias SSH',
openWithSettingsCustomTitle: 'Éditeurs personnalisés',
openWithSettingsCustomDesc: 'Nom + modèle d’URL (placeholder {path}) + famille VSCode ou non ; en mode SSH, seuls les éditeurs de la famille VSCode peuvent ouvrir un distant',
openWithSettingsAdd: 'Ajouter',
openWithSettingsName: 'Nom',
openWithSettingsTemplate: 'ex. cursor://file/{path}',
openWithSettingsFamily: 'Famille VSCode',
openWithSettingsFamilyDesc: 'Cet éditeur utilise le protocole d’URL de VSCode (prend en charge l’ouverture à distance via SSH)',
openWithSettingsRemove: 'Supprimer',
openWithSettingsInvalidHint: 'Les éditeurs sans nom ou sans modèle (contenant {path} et commençant par scheme://) n’apparaissent pas dans le menu',
openInAppDefault: 'Ouvrir avec l’application par défaut',
openInAppEmpty: 'Aucune application ne peut l’ouvrir',
openInAppFailed: 'Impossible d’ouvrir : {path}',
+23 -1
View File
@@ -57,7 +57,29 @@ export const hi: Record<string, string> = {
editorEmptyHint: 'पूर्वावलोकन शुरू करने के लिए दाएँ फ़ाइल ट्री या ऊपर पथ इनपुट से फ़ाइल चुनें',
openFileNewTab: 'नए टैब में खोलें',
openFileSide: 'बाजू में खोलें',
openInApp: 'इसके साथ खोलें',
zipDownload: 'ज़िप कर डाउनलोड करें',
zipDownloadCount: 'ज़िप कर डाउनलोड करें ({count} आइटम)',
zipFailed: 'संग्रह बनाना विफल: {message}',
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-परिवार फ़्लैग; रिमोट मोड में केवल VSCode-परिवार एडिटर रिमोट पथ खोल सकते हैं',
openWithSettingsAdd: 'जोड़ें',
openWithSettingsName: 'नाम',
openWithSettingsTemplate: 'जैसे cursor://file/{path}',
openWithSettingsFamily: 'VSCode-परिवार',
openWithSettingsFamilyDesc: 'यह एडिटर VSCode URL बोली बोलता है (SSH-रिमोट खोलने का समर्थन)',
openWithSettingsRemove: 'हटाएं',
openWithSettingsInvalidHint: 'नाम या {path} / scheme:// के बिना टेम्पलेट वाले एडिटर मेन्यू में नहीं दिखते',
openInAppDefault: 'डिफ़ॉल्ट ऐप से खोलें',
openInAppEmpty: 'इसे खोलने के लिए कोई ऐप नहीं',
openInAppFailed: 'खोल नहीं सका: {path}',
+23 -1
View File
@@ -55,7 +55,29 @@ export const id: Record<string, string> = {
editorEmptyHint: 'Pilih berkas dari panel pohon atau input path di atas untuk mulai pratinjau',
openFileNewTab: 'Buka di Tab Baru',
openFileSide: 'Buka di Samping',
openInApp: 'Buka dengan',
zipDownload: 'Zip dan unduh',
zipDownloadCount: 'Zip dan unduh ({count} item)',
zipFailed: 'Gagal membuat arsip: {message}',
openWithMenu: 'Buka dengan',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Sematkan ke menu',
unpinOpenWith: 'Lepas sematan',
openWithExplorer: 'File Manager',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Host remote SSH',
openWithSettingsSshDesc: 'Kosong = ruang kerja lokal; dengan user@host atau alias SSH, opener VSCode-family beralih ke protokol vscode-remote/ssh-remote dan File Manager / Zed / editor kustom non-VSCode-family disembunyikan dari menu',
openWithSettingsSshPlaceholder: 'user@host atau alias SSH',
openWithSettingsCustomTitle: 'Editor kustom',
openWithSettingsCustomDesc: 'Nama + template URL (placeholder {path}) + flag VSCode-family; dalam mode remote hanya editor VSCode-family yang dapat membuka path remote',
openWithSettingsAdd: 'Tambah',
openWithSettingsName: 'Nama',
openWithSettingsTemplate: 'mis. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-family',
openWithSettingsFamilyDesc: 'Editor ini menggunakan dialek URL VSCode (mendukung pembukaan remote SSH)',
openWithSettingsRemove: 'Hapus',
openWithSettingsInvalidHint: 'Editor dengan nama hilang atau template tanpa {path} / scheme:// tidak ditampilkan di menu',
openInAppDefault: 'Buka dengan aplikasi default',
openInAppEmpty: 'Tidak ada aplikasi yang bisa membukanya',
openInAppFailed: 'Gagal membuka: {path}',
+23 -1
View File
@@ -48,7 +48,29 @@ export const it: Record<string, string> = {
editorEmptyHint: 'Scelga un file dal pannello dell’albero a destra o dal campo del percorso in alto per iniziare l’anteprima',
openFileNewTab: 'Apri in una nuova scheda',
openFileSide: 'Apri a lato',
openInApp: 'Apri con',
zipDownload: 'Comprimi e scarica',
zipDownloadCount: 'Comprimi e scarica ({count} elementi)',
zipFailed: 'Creazione archivio non riuscita: {message}',
openWithMenu: 'Apri con',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fissa al menu',
unpinOpenWith: 'Rimuovi dal fisso',
openWithExplorer: 'Esplora risorse',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Host remoto SSH',
openWithSettingsSshDesc: 'Vuoto = spazio di lavoro locale; con un user@host o un alias SSH, gli apritori della famiglia VSCode passano al protocollo vscode-remote/ssh-remote e l’Esplora risorse / Zed / gli editor personalizzati non VSCode vengono nascosti dal menu',
openWithSettingsSshPlaceholder: 'user@host o alias SSH',
openWithSettingsCustomTitle: 'Editor personalizzati',
openWithSettingsCustomDesc: 'Nome + modello URL (segnaposto {path}) + flag famiglia VSCode; in modalità remota solo gli editor della famiglia VSCode possono aprire un percorso remoto',
openWithSettingsAdd: 'Aggiungi',
openWithSettingsName: 'Nome',
openWithSettingsTemplate: 'es. cursor://file/{path}',
openWithSettingsFamily: 'Famiglia VSCode',
openWithSettingsFamilyDesc: 'Questo editor usa il protocollo URL di VSCode (supporta aperture SSH remote)',
openWithSettingsRemove: 'Rimuovi',
openWithSettingsInvalidHint: 'Gli editor con nome mancante o modello senza {path} / scheme:// non vengono mostrati nel menu',
openInAppDefault: 'Apri con l’app predefinita',
openInAppEmpty: 'Nessuna applicazione può aprirlo',
openInAppFailed: 'Impossibile aprire: {path}',
+23 -1
View File
@@ -57,7 +57,29 @@ export const ja: Record<string, string> = {
editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー',
openFileNewTab: '新しいタブで開く',
openFileSide: '横に開く',
openInApp: 'アプリで開く',
zipDownload: '圧縮してダウンロード',
zipDownloadCount: '圧縮してダウンロード({count} 件)',
zipFailed: 'アーカイブの作成に失敗しました:{message}',
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:// で始まる必要あり)が未入力の編集者はメニューに表示されません',
openInAppDefault: '既定のアプリで開く',
openInAppEmpty: '開けるアプリがありません',
openInAppFailed: '開けませんでした:{path}',
+23 -1
View File
@@ -49,7 +49,29 @@ export const ko: Record<string, string> = {
editorEmptyHint: '오른쪽 파일 트리나 위쪽 경로 입력란에서 파일을 선택해 미리보기를 시작하세요',
openFileNewTab: '새 Tab으로 열기',
openFileSide: '측면에 열기',
openInApp: '앱으로 열기',
zipDownload: '압축하여 다운로드',
zipDownloadCount: '압축하여 다운로드({count}개 항목)',
zipFailed: '압축 파일 생성 실패: {message}',
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:// 로 시작해야 함)을 입력하지 않은 편집기는 메뉴에 표시되지 않습니다',
openInAppDefault: '기본 앱으로 열기',
openInAppEmpty: '이 파일을 열 수 있는 앱이 없습니다',
openInAppFailed: '열 수 없습니다: {path}',
+23 -1
View File
@@ -55,7 +55,29 @@ export const nl: Record<string, string> = {
editorEmptyHint: 'Kies een bestand uit het boompaneel of het pad-invoerveld hierboven om te beginnen met previewen',
openFileNewTab: 'Openen in nieuw tabblad',
openFileSide: 'Openen aan de zijkant',
openInApp: 'Openen met',
zipDownload: 'Zippen en downloaden',
zipDownloadCount: 'Zippen en downloaden ({count} items)',
zipFailed: 'Archiveren mislukt: {message}',
openWithMenu: 'Openen met',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Vastmaken aan menu',
unpinOpenWith: 'Losmaken',
openWithExplorer: 'Bestandsbeheerder',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH externe host',
openWithSettingsSshDesc: 'Leeg = lokale werkruimte; met een user@host of SSH-alias schakelen VSCode-familie-openers over op het vscode-remote/ssh-remote protocol en worden de Bestandsbeheerder / Zed / niet-VSCode-familie custom editors verborgen uit het menu',
openWithSettingsSshPlaceholder: 'user@host of SSH-alias',
openWithSettingsCustomTitle: 'Aangepaste editors',
openWithSettingsCustomDesc: 'Naam + URL-sjabloon ({path} placeholder) + VSCode-familie-vlag; in externe modus kunnen alleen VSCode-familie-editors een extern pad openen',
openWithSettingsAdd: 'Toevoegen',
openWithSettingsName: 'Naam',
openWithSettingsTemplate: 'bijv. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-familie',
openWithSettingsFamilyDesc: 'Deze editor spreekt het VSCode URL-dialect (ondersteunt SSH-externe openingen)',
openWithSettingsRemove: 'Verwijderen',
openWithSettingsInvalidHint: 'Editors met een ontbrekende naam of een sjabloon zonder {path} / scheme:// worden niet in het menu getoond',
openInAppDefault: 'Openen met standaardapp',
openInAppEmpty: 'Geen app kan dit openen',
openInAppFailed: 'Kan niet openen: {path}',
+23 -1
View File
@@ -59,7 +59,29 @@ export const pl: Record<string, string> = {
editorEmptyHint: 'Wybierz plik z panelu drzewa lub pola ścieżki powyżej, aby rozpocząć podgląd',
openFileNewTab: 'Otwórz w nowej karcie',
openFileSide: 'Otwórz z boku',
openInApp: 'Otwórz za pomocą',
zipDownload: 'Spakuj i pobierz',
zipDownloadCount: 'Spakuj i pobierz ({count} elementów)',
zipFailed: 'Tworzenie archiwum nie powiodło się: {message}',
openWithMenu: 'Otwórz za pomocą',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Przypnij do menu',
unpinOpenWith: 'Odepnij',
openWithExplorer: 'Menedżer plików',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Zdalny host SSH',
openWithSettingsSshDesc: 'Puste = lokalny obszar roboczy; z user@host lub aliasem SSH otwieracze rodziny VSCode przełączają się na protokół vscode-remote/ssh-remote, a Menedżer plików / Zed / niestandardowe edytory spoza rodziny VSCode są ukrywane w menu',
openWithSettingsSshPlaceholder: 'user@host lub alias SSH',
openWithSettingsCustomTitle: 'Niestandardowe edytory',
openWithSettingsCustomDesc: 'Nazwa + szablon URL (zastępnik {path}) + flaga rodziny VSCode; w trybie zdalnym tylko edytory rodziny VSCode mogą otwierać ścieżkę zdalną',
openWithSettingsAdd: 'Dodaj',
openWithSettingsName: 'Nazwa',
openWithSettingsTemplate: 'np. cursor://file/{path}',
openWithSettingsFamily: 'Rodzina VSCode',
openWithSettingsFamilyDesc: 'Ten edytor używa dialektu URL VSCode (obsługuje otwieranie zdalne przez SSH)',
openWithSettingsRemove: 'Usuń',
openWithSettingsInvalidHint: 'Edytory z brakującą nazwą lub szablonem bez {path} / scheme:// nie są pokazywane w menu',
openInAppDefault: 'Otwórz w domyślnej aplikacji',
openInAppEmpty: 'Brak aplikacji, która może to otworzyć',
openInAppFailed: 'Nie udało się otworzyć: {path}',
+23 -1
View File
@@ -40,7 +40,29 @@ export const pt: Record<string, string> = {
editorEmptyHint: 'Escolha um arquivo na árvore do painel ou no campo de caminho acima para começar a pré-visualizar',
openFileNewTab: 'Abrir em nova aba',
openFileSide: 'Abrir ao lado',
openInApp: 'Abrir com',
zipDownload: 'Compactar e baixar',
zipDownloadCount: 'Compactar e baixar ({count} itens)',
zipFailed: 'Falha ao compactar: {message}',
openWithMenu: 'Abrir com',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fixar no menu',
unpinOpenWith: 'Desafixar',
openWithExplorer: 'Gerenciador de arquivos',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Host remoto SSH',
openWithSettingsSshDesc: 'Vazio = workspace local; com user@host ou um alias SSH, os abridores da família VSCode passam a usar o protocolo vscode-remote/ssh-remote e o Gerenciador de arquivos / Zed / editores personalizados fora da família VSCode ficam ocultos do menu',
openWithSettingsSshPlaceholder: 'user@host ou alias SSH',
openWithSettingsCustomTitle: 'Editores personalizados',
openWithSettingsCustomDesc: 'Nome + modelo de URL (espaço reservado {path}) + sinalizador de família VSCode; no modo remoto, apenas editores da família VSCode podem abrir um caminho remoto',
openWithSettingsAdd: 'Adicionar',
openWithSettingsName: 'Nome',
openWithSettingsTemplate: 'ex.: cursor://file/{path}',
openWithSettingsFamily: 'Família VSCode',
openWithSettingsFamilyDesc: 'Este editor fala o dialeto de URL do VSCode (suporta aberturas SSH remotas)',
openWithSettingsRemove: 'Remover',
openWithSettingsInvalidHint: 'Editores com nome ausente ou modelo sem {path} / scheme:// não aparecem no menu',
openInAppDefault: 'Abrir com o aplicativo padrão',
openInAppEmpty: 'Nenhum aplicativo pode abri-lo',
openInAppFailed: 'Não foi possível abrir: {path}',
+23 -1
View File
@@ -53,7 +53,29 @@ export const ru: Record<string, string> = {
editorEmptyHint: 'Выберите файл из дерева справа или введите путь выше, чтобы начать предпросмотр',
openFileNewTab: 'Открыть в новой вкладке',
openFileSide: 'Открыть сбоку',
openInApp: 'Открыть с помощью',
zipDownload: 'Сжать в архив и скачать',
zipDownloadCount: 'Сжать в архив и скачать ({count} элементов)',
zipFailed: 'Сбой создания архива: {message}',
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: 'Этот редактор использует URL-протокол VSCode (поддерживает удалённое открытие по SSH)',
openWithSettingsRemove: 'Удалить',
openWithSettingsInvalidHint: 'Редакторы без имени или с шаблоном без {path} / scheme:// не появятся в меню',
openInAppDefault: 'Открыть приложением по умолчанию',
openInAppEmpty: 'Нет приложения, которое может это открыть',
openInAppFailed: 'Не удалось открыть: {path}',
+23 -1
View File
@@ -40,7 +40,29 @@ export const sv: Record<string, string> = {
editorEmptyHint: 'Välj en fil från trädpanelen eller sökvägsinmatningen ovan för att börja förhandsgranska',
openFileNewTab: 'Öppna i ny flik',
openFileSide: 'Öppna vid sidan',
openInApp: 'Öppna med',
zipDownload: 'Zippa och ladda ner',
zipDownloadCount: 'Zippa och ladda ner ({count} objekt)',
zipFailed: 'Kunde inte skapa arkivet: {message}',
openWithMenu: 'Öppna med',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Fäst i meny',
unpinOpenWith: 'Lossa',
openWithExplorer: 'Filhanterare',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH-fjärrvärd',
openWithSettingsSshDesc: 'Tomt = lokal arbetsyta; med user@host eller SSH-alias byter VSCode-familjens öppnare till vscode-remote/ssh-remote-protokollet och Filhanterare / Zed / icke-VSCode-familjens anpassade editorer döljs från menyn',
openWithSettingsSshPlaceholder: 'user@host eller SSH-alias',
openWithSettingsCustomTitle: 'Anpassade editorer',
openWithSettingsCustomDesc: 'Namn + URL-mall ({path}-platshållare) + VSCode-familj-flagga; i fjärrläge kan endast VSCode-familjens editorer öppna en fjärrsökväg',
openWithSettingsAdd: 'Lägg till',
openWithSettingsName: 'Namn',
openWithSettingsTemplate: 't.ex. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-familj',
openWithSettingsFamilyDesc: 'Denna editor talar VSCode:s URL-dialekt (stöder SSH-fjärröppning)',
openWithSettingsRemove: 'Ta bort',
openWithSettingsInvalidHint: 'Editorer med saknat namn eller en mall utan {path} / scheme:// visas inte i menyn',
openInAppDefault: 'Öppna med standardapp',
openInAppEmpty: 'Ingen app kan öppna den',
openInAppFailed: 'Kunde inte öppna: {path}',
+23 -1
View File
@@ -57,7 +57,29 @@ export const th: Record<string, string> = {
editorEmptyHint: 'เลือกไฟล์จากพาเนลต้นไม้ไฟล์หรือช่องกรอกพาธด้านบนเพื่อเริ่มพรีวิว',
openFileNewTab: 'เปิดใน Tab ใหม่',
openFileSide: 'เปิดด้านข้าง',
openInApp: 'เปิดด้วย',
zipDownload: 'บีบอัดและดาวน์โหลด',
zipDownloadCount: 'บีบอัดและดาวน์โหลด ({count} รายการ)',
zipFailed: 'สร้างไฟล์บีบอัดไม่สำเร็จ: {message}',
openWithMenu: 'เปิดด้วย',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'ปักหมุดในเมนู',
unpinOpenWith: 'ยกเลิกการปักหมุด',
openWithExplorer: 'ตัวจัดการไฟล์',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'โฮสต์ระยะไกล SSH',
openWithSettingsSshDesc: 'ว่าง = พื้นที่ทำงานในเครื่อง; เมื่อกรอก user@host หรือ alias SSH ตัวเปิดตระกูล VSCode จะสลับไปใช้โปรโตคอล vscode-remote/ssh-remote และตัวจัดการไฟล์ / Zed / ตัวแก้ไขที่กำหนดเองที่ไม่ใช่ตระกูล VSCode จะถูกซ่อนจากเมนู',
openWithSettingsSshPlaceholder: 'user@host หรือ alias SSH',
openWithSettingsCustomTitle: 'ตัวแก้ไขที่กำหนดเอง',
openWithSettingsCustomDesc: 'ชื่อ + เทมเพลต URL (placeholder {path}) + แฟล็กตระกูล VSCode; ในโหมดระยะไกลเฉพาะตัวแก้ไขตระกูล VSCode เท่านั้นที่เปิดพาธระยะไกลได้',
openWithSettingsAdd: 'เพิ่ม',
openWithSettingsName: 'ชื่อ',
openWithSettingsTemplate: 'เช่น cursor://file/{path}',
openWithSettingsFamily: 'ตระกูล VSCode',
openWithSettingsFamilyDesc: 'ตัวแก้ไขนี้ใช้โปรโตคอล URL ของ VSCode (รองรับการเปิดจากระยะไกลผ่าน SSH)',
openWithSettingsRemove: 'ลบ',
openWithSettingsInvalidHint: 'ตัวแก้ไขที่ไม่มีชื่อหรือเทมเพลตที่ไม่มี {path} / scheme:// จะไม่ปรากฏในเมนู',
openInAppDefault: 'เปิดด้วยแอปเริ่มต้น',
openInAppEmpty: 'ไม่มีแอปที่เปิดไฟล์นี้ได้',
openInAppFailed: 'เปิดไม่สำเร็จ: {path}',
+23 -1
View File
@@ -57,7 +57,29 @@ export const tr: Record<string, string> = {
editorEmptyHint: 'Önizlemeye başlamak için sağdaki dosya ağacından veya yukarıdaki yol girişinden bir dosya seçin',
openFileNewTab: 'Yeni sekmede aç',
openFileSide: 'Yana aç',
openInApp: 'Birlikte aç',
zipDownload: 'Sıkıştır ve indir',
zipDownloadCount: 'Sıkıştır ve indir ({count} öğe)',
zipFailed: 'Arşiv oluşturulamadı: {message}',
openWithMenu: 'Birlikte aç',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Menüye sabitle',
unpinOpenWith: 'Sabitlemeyi kaldır',
openWithExplorer: 'Dosya yöneticisi',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH uzak ana makine',
openWithSettingsSshDesc: 'Boş = yerel çalışma alanı; bir user@host veya SSH rumuzu verilirse VSCode ailesi açıcılar vscode-remote/ssh-remote protokolüne geçer ve Dosya yöneticisi / Zed / VSCode ailesi olmayan özel düzenleyiciler menüden gizlenir',
openWithSettingsSshPlaceholder: 'user@host veya SSH rumuzu',
openWithSettingsCustomTitle: 'Özel düzenleyiciler',
openWithSettingsCustomDesc: 'Ad + URL şablonu ({path} yer tutucusu) + VSCode ailesi bayrağı; uzak modda yalnızca VSCode ailesi düzenleyiciler uzak yolu açabilir',
openWithSettingsAdd: 'Ekle',
openWithSettingsName: 'Ad',
openWithSettingsTemplate: 'ör. cursor://file/{path}',
openWithSettingsFamily: 'VSCode ailesi',
openWithSettingsFamilyDesc: 'Bu düzenleyici VSCode URL dilini konuşur (SSH uzak açmayı destekler)',
openWithSettingsRemove: 'Kaldır',
openWithSettingsInvalidHint: 'Adı eksik veya {path}/scheme:// içermeyen şablonlu düzenleyiciler menüde gösterilmez',
openInAppDefault: 'Varsayılan uygulamayla aç',
openInAppEmpty: 'Bunu açabilecek bir uygulama yok',
openInAppFailed: 'Açılamadı: {path}',
+23 -1
View File
@@ -57,7 +57,29 @@ export const vi: Record<string, string> = {
editorEmptyHint: 'Chọn tệp từ cây tệp bên phải hoặc ô nhập đường dẫn phía trên để xem trước',
openFileNewTab: 'Mở trong Tab mới',
openFileSide: 'Mở ở bên cạnh',
openInApp: 'Mở bằng',
zipDownload: 'Nén và tải xuống',
zipDownloadCount: 'Nén và tải xuống ({count} mục)',
zipFailed: 'Tạo tệp nén thất bại: {message}',
openWithMenu: 'Mở bằng',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Ghim vào menu',
unpinOpenWith: 'Bỏ ghim',
openWithExplorer: 'Trình quản lý tệp',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Máy chủ từ xa SSH',
openWithSettingsSshDesc: 'Để trống cho không gian làm việc cục bộ; nhập user@host hoặc alias SSH, các trình mở họ VSCode sẽ chuyển sang giao thức vscode-remote/ssh-remote, Trình quản lý tệp / Zed / trình soạn thảo tùy chỉnh không phải VSCode sẽ ẩn khỏi menu',
openWithSettingsSshPlaceholder: 'user@host hoặc alias SSH',
openWithSettingsCustomTitle: 'Trình soạn thảo tùy chỉnh',
openWithSettingsCustomDesc: 'Tên + mẫu URL (placeholder {path}) + cờ họ VSCode; ở chế độ SSH chỉ họ VSCode mở được đường dẫn từ xa',
openWithSettingsAdd: 'Thêm',
openWithSettingsName: 'Tên',
openWithSettingsTemplate: 'vd: cursor://file/{path}',
openWithSettingsFamily: 'Họ VSCode',
openWithSettingsFamilyDesc: 'Trình soạn thảo này dùng giao thức URL của VSCode (hỗ trợ mở từ xa qua SSH)',
openWithSettingsRemove: 'Xóa',
openWithSettingsInvalidHint: 'Trình soạn thảo thiếu tên hoặc mẫu (cần chứa {path} và bắt đầu bằng scheme://) sẽ không xuất hiện trong menu',
openInAppDefault: 'Mở bằng ứng dụng mặc định',
openInAppEmpty: 'Không có ứng dụng nào mở được tệp này',
openInAppFailed: 'Không mở được: {path}',
+23 -1
View File
@@ -72,7 +72,29 @@ export const zhHK: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openInApp: '開啟方式',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
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:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
+23 -1
View File
@@ -72,7 +72,29 @@ export const zhMO: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openInApp: '開啟方式',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
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:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
+23 -1
View File
@@ -72,7 +72,29 @@ export const zhTW: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openInApp: '開啟方式',
zipDownload: '壓縮並下載',
zipDownloadCount: '壓縮並下載({count} 項)',
zipFailed: '壓縮失敗:{message}',
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:// 開頭)未填寫的編輯器不會出現在選單中',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用來開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
+46 -2
View File
@@ -69,7 +69,29 @@ export const zh = {
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
openInApp: '打开方式',
zipDownload: '压缩并下载',
zipDownloadCount: '压缩并下载({count} 项)',
zipFailed: '打包失败:{message}',
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:// 开头)未填写的编辑器不会出现在菜单中',
openInAppDefault: '用默认应用打开',
openInAppEmpty: '没有可用于打开它的应用',
openInAppFailed: '无法打开:{path}',
@@ -558,7 +580,29 @@ 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',
openInApp: 'Open with',
zipDownload: 'Zip and download',
zipDownloadCount: 'Zip and download ({count} items)',
zipFailed: 'Archive failed: {message}',
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',
openInAppDefault: 'Open with default app',
openInAppEmpty: 'No application can open it',
openInAppFailed: 'Could not open: {path}',
+68
View File
@@ -0,0 +1,68 @@
/**
* Submenu flip geometry for the portaled context menus.
*
* The primitives `Menu` clamps its main list into the viewport (portal mode,
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
* the card) — with no viewport clamp at all. This sidebar lives docked on the
* RIGHT edge of the window, so both defaults are the worst case here: a tall
* "open with" submenu pokes past the viewport top when the row menu opens
* near the top, and past the right edge whenever the panel is narrow.
*
* The host primitive is not ours to patch, so the fix is a body attribute
* written while one of OUR submenu-bearing menus is open: layout.css flips
* the submenu's growth direction per token. The attribute's lifetime IS the
* scope — only our menus set it, and layout.css rules for
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
* host-owned menus are never touched.
*/
import { useEffect } from 'react'
/** body attribute carrying the flip tokens (whitespace-separated words). */
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
/**
* Mirror of the primitive's submenu geometry (Menu.module.css): card
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
* needs. If the cursor is within that of the right edge, open left instead.
* The estimate is conservative (the primitive's own clamp can only move the
* card further left), so a wrong guess flips left unnecessarily — never into
* a new overflow — and drifting from future host metrics degrades the same
* way, back to today's behavior at worst.
*/
const SUBMENU_RIGHT_ROOM = 400
/** Below the vertical midpoint the parent row usually has more room above. */
function isUpperHalf(y: number, vh: number): boolean {
return y < vh / 2
}
/**
* The flip tokens for a menu opened at viewport coordinates (x, y):
* `down` — open the submenu downward from the parent row (cursor in the
* upper half; the sidebar's tab strip is always up there);
* `left` — open the submenu leftward of the card (not enough right-hand
* room). Empty string when the primitive's defaults already fit.
*/
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
const tokens: string[] = []
if (isUpperHalf(y, vh)) tokens.push('down')
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
return tokens.join(' ')
}
/**
* Publish the flip tokens on <body> while the given cursor-anchored menu
* state is open; clear them on close and on unmount (a menu left open by an
* unmounting panel must not leave stale geometry on the shared body).
* `menu` only needs the viewport coordinates the Menu anchored to.
*/
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
useEffect(() => {
if (menu === null) return
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
}, [menu])
}
+135
View File
@@ -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 { IconCloseOutlineRegular } 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) }}
>
<IconCloseOutlineRegular size={14} />
</button>
</div>
))}
{hasInvalid && (
<div className={css.openWithHint} role="note">
{t('openWithSettingsInvalidHint')}
</div>
)}
</div>
)
}
+221
View File
@@ -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())
}
+43
View File
@@ -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)
})
}
+53
View File
@@ -2108,6 +2108,59 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
}
/* The file tree's plugin "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. Sized for the compact menu's
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
.openWithPin {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 5px;
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);
}
/* ── Markdown preview: raw-HTML runs + table of contents ─────────────────
The HTML runs lifted out of the markdown source (MarkdownHtml.tsx) render
as sanitized block leaves; the TOC is a zero-height sticky bar keeping the