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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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
@@ -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}',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The file tree's "open with" vocabulary: which external editors / file
|
||||
* managers the context menu can hand a row's path to, how the per-user
|
||||
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
|
||||
* persisted blob, and how the external URL is built (local `file` URLs and
|
||||
* VSCode-family SSH-remote URLs).
|
||||
*
|
||||
* Pure by design (no React / no `api`), so the whole surface is unit-testable
|
||||
* without a DOM or a host route. The menu and the settings panel only consume
|
||||
* the exported types and functions.
|
||||
*/
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
|
||||
export interface CustomEditor {
|
||||
id: string
|
||||
/** Human label shown in the menu (e.g. "Windsurf"). */
|
||||
name: string
|
||||
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
|
||||
urlTemplate: string
|
||||
/** Whether the editor speaks the VSCode URL dialect — the only editors
|
||||
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
|
||||
isVscodeFamily: boolean
|
||||
}
|
||||
|
||||
/** The per-user "open with" configuration (one key of the editor blob). */
|
||||
export interface OpenWithConfig {
|
||||
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
|
||||
sshHost: string
|
||||
/** User-defined editors, appended after the built-ins. */
|
||||
customEditors: CustomEditor[]
|
||||
/** Ids of open-with targets pinned to the menu's top level
|
||||
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
|
||||
pinned: string[]
|
||||
}
|
||||
|
||||
/** One menu-visible open target (built-in or custom, SSH-filtered). */
|
||||
export interface OpenWithTarget {
|
||||
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
|
||||
id: string
|
||||
/** Locale key of a built-in label (custom editors carry `name` instead). */
|
||||
nameKey?: CopyKey
|
||||
/** User-defined label (custom editors only; '' for built-ins). */
|
||||
name: string
|
||||
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
|
||||
kind: 'reveal' | 'url'
|
||||
/** URL template with `{path}`; undefined for reveal targets. */
|
||||
urlTemplate?: string
|
||||
/** Whether the editor talks the VSCode URL dialect. */
|
||||
isVscodeFamily: boolean
|
||||
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
|
||||
localOnly: boolean
|
||||
}
|
||||
|
||||
/** The default open-with configuration (fresh documents). */
|
||||
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
|
||||
sshHost: '',
|
||||
customEditors: [],
|
||||
pinned: [],
|
||||
}
|
||||
|
||||
/** The built-in open targets, in menu order. */
|
||||
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
|
||||
{
|
||||
id: 'explorer',
|
||||
nameKey: 'openWithExplorer',
|
||||
name: '',
|
||||
kind: 'reveal',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
nameKey: 'openWithVscode',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'vscode://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
nameKey: 'openWithCursor',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'cursor://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
nameKey: 'openWithZed',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'zed://file/{path}',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a persisted value makes a structurally valid custom-editor row.
|
||||
* Name/template may be empty — the settings panel edits rows in place and
|
||||
* an in-progress row must survive the round-trip; the MENU hides rows that
|
||||
* fail the stricter {@link isValidCustomEditor} check. */
|
||||
function isCustomEditor(value: unknown): value is CustomEditor {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.id === 'string' && record.id !== ''
|
||||
&& typeof record.name === 'string'
|
||||
&& typeof record.urlTemplate === 'string'
|
||||
&& typeof record.isVscodeFamily === 'boolean'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
|
||||
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
|
||||
* are kept verbatim (unknown ids are pruned when the targets are resolved —
|
||||
* the menu is the only consumer of the resolved list).
|
||||
*/
|
||||
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
|
||||
const record = raw as Record<string, unknown>
|
||||
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
|
||||
const customEditors = Array.isArray(record.customEditors)
|
||||
? record.customEditors.filter(isCustomEditor)
|
||||
: []
|
||||
const pinned = Array.isArray(record.pinned)
|
||||
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
: []
|
||||
return { sshHost, customEditors, pinned }
|
||||
}
|
||||
|
||||
/** Whether a custom editor id belongs to this config (id prefix match). */
|
||||
function customIdOf(id: string): string {
|
||||
return `custom:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu-visible open targets, in order (built-ins then custom editors).
|
||||
* In SSH mode the local-only targets (the OS file manager, Zed, custom
|
||||
* editors without the VSCode dialect) are dropped — they cannot reach a
|
||||
* remote path. Unknown pinned ids are pruned here too.
|
||||
*/
|
||||
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
|
||||
const ssh = config.sshHost.trim() !== ''
|
||||
const targets: OpenWithTarget[] = [
|
||||
...OPEN_WITH_BUILTINS,
|
||||
...config.customEditors
|
||||
.filter(isValidCustomEditor)
|
||||
.map((editor): OpenWithTarget => ({
|
||||
id: customIdOf(editor.id),
|
||||
name: editor.name,
|
||||
kind: 'url',
|
||||
urlTemplate: editor.urlTemplate,
|
||||
isVscodeFamily: editor.isVscodeFamily,
|
||||
localOnly: !editor.isVscodeFamily,
|
||||
})),
|
||||
]
|
||||
return targets.filter(target => !(ssh && target.localOnly))
|
||||
}
|
||||
|
||||
/** The SSH hint appended to a target's label in remote mode. */
|
||||
export function openWithSshActive(config: OpenWithConfig): boolean {
|
||||
return config.sshHost.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to open for one resolved target, or undefined when the target has
|
||||
* no URL form (reveal) or the template is malformed. The path is inserted
|
||||
* RAW into the template (browsers percent-encode as needed; VSCode-family
|
||||
* URL parsers consume the absolute path with its leading slash, e.g.
|
||||
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
|
||||
*/
|
||||
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
|
||||
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
|
||||
const normalized = normalizeUrlPath(path)
|
||||
const ssh = openWithSshActive(config)
|
||||
if (ssh && target.isVscodeFamily) {
|
||||
const scheme = schemeOf(target.urlTemplate)
|
||||
if (scheme === undefined) return undefined
|
||||
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
|
||||
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
|
||||
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
|
||||
}
|
||||
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
|
||||
return target.urlTemplate.replace('{path}', normalized)
|
||||
}
|
||||
|
||||
/** Whether a template starts with a `scheme://` prefix (the only shape the
|
||||
* host's external opener accepts and the settings panel suggests). */
|
||||
function hasUrlScheme(template: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
|
||||
}
|
||||
|
||||
/** The scheme of a URL template (the part before the first ':'), or undefined. */
|
||||
function schemeOf(template: string): string | undefined {
|
||||
const at = template.indexOf(':')
|
||||
if (at <= 0) return undefined
|
||||
const scheme = template.slice(0, at)
|
||||
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
|
||||
}
|
||||
|
||||
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
|
||||
export function normalizeUrlPath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/** A fresh custom-editor id (uuid when available, time-based fallback). */
|
||||
export function newCustomEditorId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** Validate one custom-editor row before the settings panel accepts it. */
|
||||
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
|
||||
return row.name.trim() !== ''
|
||||
&& row.urlTemplate.includes('{path}')
|
||||
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Pending-writes queue for the file tree's open-with config: pin toggles and
|
||||
* (outside the settings popup) config edits land in the sidebar prefs as
|
||||
* `pluginSettings['editor']`. Writes are serialized through one promise chain
|
||||
* so a quick burst of pin clicks can never read a stale pluginSettings map
|
||||
* and drop an earlier toggle; each write pushes the whole open map patch
|
||||
* through the revision-free settings route and adopts the returned document.
|
||||
*
|
||||
* (The settings popup has its own serialized commit — SideCardSection's —
|
||||
* so its rows and this helper rarely race; the shared route's last-write-wins
|
||||
* semantics cover the uncommon overlap.)
|
||||
*/
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Merge one plugin-owned settings blob of one descriptor and persist it.
|
||||
* @param store - the sidebar store (its prefs are replaced by the write result).
|
||||
* @param descriptorId - the descriptor whose blob is patched ('editor' here).
|
||||
* @param updater - pure patch function; receives a shallow copy of the blob.
|
||||
*/
|
||||
export function updatePluginSettings(
|
||||
store: SidebarStore,
|
||||
descriptorId: string,
|
||||
updater: (blob: Record<string, unknown>) => Record<string, unknown>,
|
||||
): void {
|
||||
queue = queue.then(async () => {
|
||||
const prefs = store.getPrefs()
|
||||
const blob = prefs.pluginSettings[descriptorId] ?? {}
|
||||
const next = updater({ ...blob })
|
||||
const view = await api.settingsUpdate({
|
||||
pluginSettings: { ...prefs.pluginSettings, [descriptorId]: next },
|
||||
})
|
||||
store.setPrefs(parsePrefs(view.value))
|
||||
}).catch((error: unknown) => {
|
||||
// The pin stays visually unchanged (no optimistic flip) and the menu
|
||||
// keeps working — the write failure is logged, not surfaced.
|
||||
console.error('open-with settings write failed', error)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user