feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取

文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
This commit is contained in:
Menghuan1918
2026-09-28 00:27:31 +08:00
parent 606339f81e
commit 36fbc5ac7e
76 changed files with 6961 additions and 3944 deletions
+13 -146
View File
@@ -5,157 +5,24 @@
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the changes tab's staging/discard operations change the
* very content it shows. Rendering goes through the shared {@link DiffFiles}
* renderer — the same one the changes tab's inline preview uses.
* very content it shows. Loading IS the shared `useGitDiffTarget` hook (the
* changes tab's inline preview runs the same one, so the staged-side and
* untracked fallbacks and the per-file fold cache cannot drift apart);
* rendering goes through the shared {@link DiffFiles} renderer.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMemo } from 'react'
import { IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffFiles } from './diff/DiffFiles.tsx'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './diff/rows.ts'
import { useGitDiffTarget } from './diff/use-git-diff.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
setLoading(true)
setError(null)
setData(null)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!diff.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
// A child-repo path is relative to diff.repoRoot, not the session
// cwd or the linked-worktree root; resolve against whichever the
// diff ref carries so the untracked fallback reads the right file.
const text = await api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, diff.path))
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the ref or a refresh tick. ──────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [diff, tick])
const foldLoader = useMemo(() => {
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
const scope: SessionScope = { sessionId, cwd, ...(diff.repoRoot !== undefined ? { repoRoot: diff.repoRoot } : {}) }
if (diff.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, `${diff.hashFull}^`, displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, diff.hashFull, displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? diff.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, 'HEAD', displayPath(file.oldPath), diff.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.newPath), diff.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(scope, ':0', displayPath(file.oldPath), diff.worktree),
api.fsRead(scope, resolveSidebarPath(diff.repoRoot ?? diff.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [sessionId, cwd, diff, effectiveStaged])
const scope = useMemo<SessionScope>(() => ({ sessionId, cwd }), [sessionId, cwd])
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(diff, scope)
return (
<div className={css.gitDiffTab}>
@@ -175,12 +42,12 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
{!loading && error === null && diffText !== null && (
<>
{data.untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} resolveFold={foldLoader} />}
{data.diff === '' && data.untracked === undefined && (
{untracked !== undefined
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={untracked} />
: <DiffFiles diff={diffText} resolveFold={resolveFold} />}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
+14 -60
View File
@@ -34,8 +34,7 @@ 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'
import { relativeTo } from './paths.ts'
@@ -56,10 +55,6 @@ 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)
@@ -98,12 +93,16 @@ export function EditorHost(props: {
store: SidebarStore
scope: SessionScope
tab: SidebarTab
/** Whether this tab is the active one with its panel open: a parked tab
* must not keep polling (the workbench keeps every tab body mounted). */
visible?: boolean
expanded: string[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const visible = props.visible !== false
const path = tab.path ?? ''
const title = tab.title
// A folder window: the model's `sidebar_open` (or any caller) opens a
@@ -135,16 +134,11 @@ export function EditorHost(props: {
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// The file tree's "open with" configuration (pluginSettings['editor']): a
// blob subscription, so a pin click or a settings-page edit re-renders the
// menu immediately. The parsed config also drives which targets are shown
// (SSH mode hides the host-local ones).
const editorBlob = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
)
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// The DSH-native "open with" capability (host open-in-app): one adapter per
// window, shared by every row menu below. The plugin no longer owns a
// 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])
// 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
@@ -191,40 +185,6 @@ 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.
@@ -384,6 +344,7 @@ export function EditorHost(props: {
<div className={css.editor}>
<TreePanel
full
visible={visible}
store={store}
sessionId={scope.sessionId}
cwd={folderRoot ?? scope.cwd}
@@ -393,11 +354,7 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
openInApp={openInApp}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
@@ -509,6 +466,7 @@ export function EditorHost(props: {
onPointerCancel={onResizeEnd}
/>
<TreePanel
visible={visible}
store={store}
sessionId={scope.sessionId}
cwd={scope.cwd}
@@ -518,11 +476,7 @@ export function EditorHost(props: {
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithConfig.pinned}
openWithSsh={openWithSshActive(openWithConfig)}
onOpenWith={openWith}
onToggleOpenWithPin={toggleOpenWithPin}
openInApp={openInApp}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
+891 -361
View File
File diff suppressed because it is too large Load Diff
+86 -84
View File
@@ -1,11 +1,14 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
* over the shared controlled FileTree. The results panel and the tree are
* BOTH mounted: the query only parks one of them (`hidden`), so clearing the
* search is free — the tree keeps its level cache and the live directory
* watcher (the old conditional render dropped both and refetched the whole
* visible set on every query change). Owns its refresh tick: the icon next to
* the search input clears the tree cache. EditorHost docks it as the tab's
* right panel (wrapped in a drag-resize handle) and provides the file
* context-menu open escapes.
*
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
* all funnel through here: one session at a time, shown in a full-window
@@ -23,9 +26,10 @@ import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenWithTarget } from './open-with.ts'
import type { OpenInApp } from './open-in-app.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import { IconButton } from './ui/index.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
import {
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
@@ -56,25 +60,27 @@ export function TreePanel(props: {
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The "open with" menu surface (passed through to FileTree; absent →
/** The host's open-in-app handle (passed through to FileTree; absent →
* the whole section is hidden). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
openInApp?: OpenInApp
onReferenceFile: (path: string, isDir: boolean) => void
/** A tree rename landed (passed through to FileTree for tab retargeting). */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A tree delete landed (passed through to FileTree for tab closing). */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Whether the owning tab is on screen: a parked tab stops the tree's git
* polling (the shared status store pauses when nothing visible wants it). */
visible?: boolean
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
service?: BetterSidebarService
}) {
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props
const {
sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
openInApp, onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
} = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -153,6 +159,7 @@ export function TreePanel(props: {
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
const needle = query.trim()
const searching = needle !== ''
useEffect(() => {
if (needle === '') {
setResults(null)
@@ -188,35 +195,23 @@ export function TreePanel(props: {
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
<IconButton
label={t('refresh')}
icon={<IconRefreshOutlineRegular size={14} />}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFiles')}
title={t('uploadFiles')}
/>
<IconButton
label={t('uploadFiles')}
icon={<IconUploadOutline16 size={14} />}
disabled={busy}
onClick={() => { fileInputRef.current?.click() }}
>
<IconUploadOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('uploadFolder')}
title={t('uploadFolder')}
/>
<IconButton
label={t('uploadFolder')}
icon={<IconFolderOpenRegular size={14} />}
disabled={busy}
onClick={() => { folderInputRef.current?.click() }}
>
<IconFolderOpenRegular size={14} />
</button>
/>
<input
ref={fileInputRef}
type="file"
@@ -242,53 +237,60 @@ export function TreePanel(props: {
{uploadStatus !== '' && (
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
)}
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
store={store}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
service={service}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
{/* Search results: parked (not unmounted) while the query is empty. Its
contents render only for a live query — a parked panel must not put
state text (the loading line) into the panel's DOM. */}
<div
className={clsx(css.explorerBody, !searching && css.explorerHiddenPane)}
hidden={!searching}
>
{searching && (
<>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</>
)}
</div>
{/* The tree stays MOUNTED while searching: its level cache and the
directory watcher survive a query, so clearing the box is free. */}
<FileTree
sessionId={sessionId}
cwd={cwd}
store={store}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openInApp={openInApp}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
hidden={searching}
visible={visible !== false}
service={service}
/>
{upload !== null && (
<UploadOverlay
dir={upload.dir}
+1 -1
View File
@@ -39,7 +39,7 @@ export function UploadOverlay(props: {
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
<div className={css.uploadOverlayCard}>
<div className={css.uploadOverlayTitle} title={dir}>
<IconUploadOutline16 size={16} />
<IconUploadOutline16 size={14} />
<span>{t('uploadingTo', { dir })}</span>
</div>
<div
+5 -49
View File
@@ -239,51 +239,6 @@ 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) =>
@@ -307,6 +262,11 @@ export const api = {
* row unlinks the link only). The UI confirms before calling this. */
fsRemove: (scope: SessionScope, path: string) =>
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** Create one directory row inside `path` (single-segment name; the server
* refuses existing destinations, the workspace root, and — while the fence
* is armed — anything resolving outside the workspace). */
fsMkdir: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.mkdir', scopePayload(scope, { path, name })),
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
* `relativePath`); the host streams it under the session workspace. */
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
@@ -417,10 +377,6 @@ 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). */
+5 -8
View File
@@ -19,7 +19,6 @@ 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'
@@ -45,9 +44,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 "open with" configuration (SSH host +
// custom editors) is the custom panel BELOW those rows — the settings
// seam renders rows first, custom panel after.
// 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.
settings: {
toggles: [{
key: 'editorExplorer',
@@ -73,16 +72,14 @@ export function builtinTabs(): readonly TabDescriptor[] {
title: () => t('settingsFenceTitle'),
desc: () => t('settingsFenceDesc'),
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
),
},
component: ({ ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile }) => (
component: ({ ctx, store, scope, tab, visible, expanded, revealed, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
visible={visible}
expanded={expanded ?? []}
revealed={revealed ?? []}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
+122 -62
View File
@@ -2,27 +2,33 @@
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
* A 36px header carries the host {@link SegmentedControl} and the current
* lens's refresh action; both lenses preview their selections in a shared
* resizable bottom pane ({@link DiffPane}), and git targets expand into the
* dedicated diff tab docked in the workbench's diff pane. The active lens and
* the pane height persist in the tab's meta, so the tab reopens exactly where
* it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
* The session events ride the host's `changes.ops` route (the client runtime
* exposes no event-log face): the tab pulls the delta past its cursor and
* folds it into ops, and publishes the op count to a module-level cache the
* tab-strip badge reads. The pull is one catch-up whenever the tab becomes
* visible and then a cadence ONLY while the session lens is on screen — the
* git lens has no use for the session event log (and the shared git-status
* store owns its own poller).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutlineRegular, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import type { SidebarDiffRef } from '../state.ts'
import { IconButton, Notice } from '../ui/index.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { DiffPane, clampPaneHeight, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
@@ -51,18 +57,28 @@ interface ChangesMeta {
previewH?: number
}
/** What the user selected to preview: a git ref, or the IDENTITY of a session
* op. Holding the callId (not the op object) is what makes the pane follow
* the op as it settles: every render re-derives the op from the latest fold,
* so a running call turns into its result without the user re-clicking. */
type PreviewSelection =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; callId: string }
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
const [selection, setSelection] = useState<PreviewSelection | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(() => clampPaneHeight(
typeof meta.previewH === 'number' ? meta.previewH : PANE_HEIGHT_DEFAULT,
))
/** The header refresh action for the Git lens (the lens owns its own queue). */
const [gitRefresh, setGitRefresh] = useState(0)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
// ── Session-event accumulation: one catch-up pull when the tab becomes
// visible, then a 2.5s delta poll while the SESSION lens is on screen.
// The cursor is the last delivered seq, so each poll ships only what the
// accumulator lacks. ───────────────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
@@ -72,13 +88,30 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
/** One delta request in flight at a time (the poller is fixed-interval, so a
* slow host would otherwise run two pulls over the same cursor and append
* the same delta twice). A pull that arrives while one is running is a
* no-op: the in-flight answer already carries everything up to now. */
const pullingRef = useRef(false)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const pull = useCallback(async (signal?: AbortSignal): Promise<void> => {
const generation = pollGen.current
if (pullingRef.current) return
pullingRef.current = true
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
const { events, lastSeq } = await api.changesOps(scope, seqRef.current, signal)
// An aborted poll belongs to a torn-down run: its answer must never
// publish state (the transport may deliver it anyway).
if (generation !== pollGen.current || signal?.aborted === true) return
// Nothing new: KEEP the previous fold's identity. Re-folding an
// unchanged window produced a fresh array every 2.5s, which defeated
// every downstream memo — the session lens re-encoded each op body into
// a Blob to size it, on every idle tick.
if (events.length === 0 && lastSeq <= seqRef.current && opsRef.current.length > 0) {
setOpsError(false)
return
}
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
@@ -92,7 +125,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
if (generation === pollGen.current && signal?.aborted !== true) setOpsError(true)
} finally {
pullingRef.current = false
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
@@ -105,15 +140,24 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
// One pull on every input change — mount, scope change, and visibility
// flip all re-pull (a hidden tab still catches up on the flip); only the
// poll CADENCE below is gated by visibility.
useEffect(() => { void pull() }, [visible, pull])
usePolling(visible, pull, { intervalMs: 2_500 })
// One catch-up whenever the tab becomes visible (the badge count follows the
// tab being opened); the CADENCE below runs only while this lens is showing.
useEffect(() => { if (visible) void pull() }, [visible, pull])
usePolling(visible && lens === 'session', pull, { intervalMs: 2_500 })
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
/** The preview target of the current selection, re-derived every render so
* an op that settled (or fell out of the window) is reflected immediately. */
const previewTarget = useMemo<ChangesPreview | null>(() => {
if (selection === null) return null
if (selection.kind === 'git') return selection
const op = ops.find(candidate => candidate.callId === selection.callId)
if (op === undefined) return null
return { kind: 'op', path: selection.path, op, prior: knownContentBefore(ops, selection.path, op) }
}, [selection, ops])
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
@@ -127,21 +171,29 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
/** The header's refresh action: the Git lens reloads through its own queue
* (a click while a refresh is in flight is queued, not dropped), the
* session lens re-pulls its event delta. */
const refreshCurrent = (): void => {
if (lens === 'git') setGitRefresh(value => value + 1)
else void pull()
}
/** Preview one session op (with its best-effort prior content snapshot). */
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setSelection({ kind: 'git', ref })
}
/** Preview one session op by callId (the tab re-derives it each render). */
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
setSelection({ kind: 'op', path, callId: op.callId })
}
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
onOpenDiff?.(diffTabOf(preview.ref))
if (previewTarget?.kind !== 'git') return
onOpenDiff?.(diffTabOf(previewTarget.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
@@ -152,27 +204,23 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
<div className={css.head}>
<SegmentedControl
id={`changes-lens-${tab.id}`}
value={lens}
options={[
{ value: 'git', label: t('changesGitLens') },
{ value: 'session', label: t('changesSessionLens') },
]}
onChange={chooseLens}
label={t('changes')}
/>
<IconButton
className={css.headAction}
icon={<IconRefreshOutlineRegular />}
label={t('refresh')}
onClick={refreshCurrent}
/>
</div>
{lens === 'git'
? (
@@ -180,9 +228,10 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
scope={scope}
store={store}
visible={visible}
refreshTick={gitRefresh}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
selectedRef={previewTarget !== null && previewTarget.kind === 'git' ? previewTarget.ref : null}
/>
)
: (
@@ -190,20 +239,31 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
selectedCallId={selection !== null && selection.kind === 'op' ? selection.callId : null}
/>
)}
{preview !== null && (
{previewTarget !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
key={previewKey(previewTarget)}
target={previewTarget}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onClose={() => { setSelection(null) }}
onExpand={expandPreview}
/>
)}
{selection !== null && selection.kind === 'op' && previewTarget === null && (
<div className={css.paneGone}>
<Notice kind="warn">{t('changesOpGone')}</Notice>
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={() => { setSelection(null) }}
/>
</div>
)}
</div>
)
}
+61 -199
View File
@@ -2,16 +2,16 @@
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
* through the shared {@link useGitDiffTarget} hook (refreshable, with the
* untracked full-addition fallback); op targets are pure snapshots (diff /
* read view / error text). The pane is resizable by drag (clamped; the
* height commits to the tab's persisted meta on release) and by keyboard;
* git targets can expand into a dedicated diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconRightUpOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
@@ -21,7 +21,8 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { useGitDiffTarget } from '../diff/use-git-diff.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
@@ -29,6 +30,7 @@ import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import { Chip, IconButton, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -36,6 +38,16 @@ import diffCss from '../diff/diff.module.css'
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/**
* Clamp one pane height to the pane's own bounds: never below the content
* minimum, never above 70% of the window. Both the drag handler and the
* RESTORE path (a height persisted under a taller window) run through this,
* so a saved value can never open the pane taller than the viewport allows.
*/
export function clampPaneHeight(value: number): number {
return Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
}
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
@@ -84,21 +96,10 @@ export function HtmlRenderPreview(props: { src: string; title: string }) {
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
/** One header pill toggle — the redaction / reading / render toggles share the
* shape: the global {@link Chip} (its active state is the "on" picture). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
return <Chip active={props.on} title={props.title} onClick={props.onClick}>{props.label}</Chip>
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
@@ -157,146 +158,10 @@ export interface DiffPaneProps {
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
// ── Git target loading (the shared loader: staged-side fallback, the
// untracked full-addition fallback, refresh by tick, fold cache). ─────
const gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo<SessionScope>(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ───────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (gitRef.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [gitRef, gitScope, effectiveStaged, scope])
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(gitRef, scope)
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
@@ -422,7 +287,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const clamp = clampPaneHeight
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
@@ -471,15 +336,15 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
<StatusBadge tone={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
<StatusBadge tone="neutral">{target.ref.staged ? t('staged') : t('unstaged')}</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
<StatusBadge tone="neutral">{target.ref.hash}</StatusBadge>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
@@ -490,25 +355,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={refresh}
/>
<IconButton
size="sm"
icon={<IconRightUpOutlineRegular size={14} />}
label={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutlineRegular size={14} />
</button>
/>
</>
)}
{redactionHit && (
@@ -543,15 +402,12 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
<IconButton
size="sm"
icon={<IconCloseOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutlineRegular size={14} />
</button>
/>
</div>
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
@@ -566,9 +422,9 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
<Notice kind="error" className={css.readError}>
{op.errorText ?? t('changesError')}
</div>
</Notice>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
@@ -582,26 +438,32 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
&& <Notice kind="hint" tone="inline">{t('changesPriorUnknown')}</Notice>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
? <div className={css.paneBody}><Notice kind="loading" tone="page">{t('loading')}</Notice></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
? (
<div className={css.paneBody}>
<Notice kind="error">{t('diffLoadError')}: {error}</Notice>
</div>
)
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
{/* The untracked fallback has NO diff text (git diff never
lists the file): its content is the whole render. */}
{((diffText !== null && diffText !== '') || untracked !== undefined) && (
<DiffFiles
diff={diffText}
resolveFold={foldLoader}
diff={diffText ?? ''}
resolveFold={resolveFold}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
<Notice kind="empty" tone="page">{t('diffEmpty')}</Notice>
)}
</div>
)}
File diff suppressed because it is too large Load Diff
+21 -24
View File
@@ -7,15 +7,18 @@
* arrive pre-folded from the tab (which owns the event poll); this
* component is purely presentational.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { relativeTime, t } from '../locales.ts'
import { groupByFile, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import { Chip, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
const KIND_LABEL = { read: 'changesRead', write: 'changesWrite', edit: 'changesEdit' } as const
export interface SessionLensProps {
/** The folded file operations, newest first (the tab's poll owns them). */
ops: readonly FileOp[]
@@ -41,19 +44,6 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
@@ -71,15 +61,24 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
<Chip active={filter === 'all'} count={ops.length} onClick={() => { setFilter('all') }}>
{t('changesFilterAll')}
</Chip>
{(['write', 'edit', 'read'] as const).map(kind => (
<Chip
key={kind}
active={filter === kind}
count={counts.get(kind) ?? 0}
onClick={() => { setFilter(kind) }}
>
{t(KIND_LABEL[kind])}
</Chip>
))}
</div>
<div className={css.sessionList}>
{loadError && <div className={css.loadError}>{t('changesLoadError')}</div>}
{ops.length === 0 && !loadError && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{loadError && <Notice kind="error" role="alert">{t('changesLoadError')}</Notice>}
{ops.length === 0 && !loadError && <Notice kind="empty" tone="page">{t('changesSessionEmpty')}</Notice>}
{ops.length > 0 && filteredOps.length === 0 && <Notice kind="empty" tone="page">{t('changesFilterEmpty')}</Notice>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
@@ -93,9 +92,7 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<StatusBadge tone={op.kind}>{t(KIND_LABEL[op.kind])}</StatusBadge>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
+85 -282
View File
@@ -1,18 +1,20 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
/* The unified changes tab: the 36px lens header + Git lens + session lens +
the shared preview pane. Token-only by contract (`tests/theme.spec.ts` scans
every module sheet) and cut to the plugin's native scale — 12px body, 11px
meta, 28px rows/controls, 2/3/4px radii (see
docs/plans/2026-09-27-files-changes-uiux.md §1).
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
The generic chrome — icon buttons, chips, section bands, badges, empty /
loading / error lines and the confirm dialog — now lives in
`src/client/ui/kit.module.css`; this sheet keeps only layout and the
surfaces unique to this tab. */
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: var(--dsw-font-xxs-12); }
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Lens header ───────────────────────────────────────────────────────── */
.head { flex: none; display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 8px 0 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.headAction { margin-left: auto; }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
@@ -28,311 +30,134 @@
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
/* The selector band: branch / repository / worktree pickers (only rendered
for real choices) and nothing else — the counts live in the section bands. */
.selectors { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 12px 0; }
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
.select {
flex: 1;
min-width: 0;
height: 26px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
/* 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); }
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
/* 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; }
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
/* One changed file: badge + name + dimmed directory + the trailing action. */
.row {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
min-height: 28px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
padding: 0 4px 0 8px;
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); }
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
.rowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
align-items: baseline;
gap: 6px;
border: none;
background: transparent;
padding: 3px 0;
padding: 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
.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; }
/* ── Commit bar (docked to the bottom edge of the scroll area) ─────────── */
.commitBar {
position: sticky;
bottom: 0;
z-index: 1;
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts;
untracked '?' reads as a not-yet-added file, so it shares the added tint. */
.gitBadge[data-letter='A'], .gitBadge[data-letter='?'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
background: var(--dsw-alias-bg-base);
}
.commitRow { display: flex; align-items: center; gap: 6px; }
.commitInput { flex: 1; min-width: 0; }
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* ── 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). */
.gitLogRow {
.logRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
padding: 4px 12px;
margin: 0 6px;
border-radius: 4px;
cursor: pointer;
border-radius: 8px;
}
.logRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.logRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.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); }
.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); }
.gitLogRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
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;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
.logMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
.logMore:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.logMore:disabled { opacity: 0.5; cursor: default; }
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 6px 12px; flex-wrap: wrap; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.loadError { margin: 4px 8px; padding: 6px 10px; border-left: 3px solid var(--dsw-alias-state-warn-primary); border-radius: 0 6px 6px 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.fileGroup { margin-bottom: 6px; }
/* The group label sits between op blocks: extra head padding separates it from
the previous file's rows, the first group starts flush. */
.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%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.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: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); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind chips
/* Size + relative time ride as one right-aligned cluster so the kind badges
form a scannable left rail and the meta column stays flush right. */
.opMeta { margin-left: auto; display: inline-flex; align-items: baseline; gap: 8px; flex: none; min-width: 0; }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
@@ -342,10 +167,7 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 2px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
@@ -355,36 +177,18 @@
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* The path keeps its tail (the file name is the identity; directories are
context): an RTL paragraph renders an ASCII path in reading order while
clipping its HEAD under the ellipsis. */
.diffPath { flex: 1; direction: rtl; font-family: var(--ds-font-family-code, monospace); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* An op's error body: the kit's error ink with the row's inset bar. */
.readError { border-left: 3px solid var(--dsw-alias-state-error-primary); margin: 6px 8px; padding: 6px 10px; word-break: break-word; }
/* The op preview whose callId left the loaded event window. */
.paneGone { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
/* ── Shared bits ───────────────────────────────────────────────────────── */
@@ -393,15 +197,14 @@
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
/* 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 {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
.row { animation: none; }
}
+183
View File
@@ -0,0 +1,183 @@
/**
* The ONE loader behind every git diff surface: the changes tab's inline
* preview pane (`changes/DiffPane.tsx`) and the dedicated diff tab
* (`DiffTab.tsx`) used to carry byte-identical copies of three behaviors —
* the staged-side fallback (an empty requested side retries the other one),
* the untracked full-addition fallback (`git diff` never lists an untracked
* file, so its content is read through the media route), and the per-file
* fold-content cache the on-demand hunk expansion shares between sibling
* folds. They live here once so a fix in one surface cannot miss the other.
*
* The hook is inert (`loading:false`, no fetch) for a `null` ref, which is
* how the preview pane hosts non-git (session-op) targets.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import type { SidebarDiffRef } from '../state.ts'
import { resolveSidebarPath } from '../paths.ts'
import { displayPath, foldRowsFromContents, type DiffFile, type DiffRow, type FoldSegment } from './rows.ts'
/** The loaded state of one git diff target. */
export interface GitDiffTarget {
/** A fetch is in flight (true only while nothing has loaded yet, or after a refresh drops the data). */
loading: boolean
error: string | null
/** The patch text; `''` = the file genuinely has no text change. */
diffText: string | null
/** Untracked-file content rendered as a full addition (undefined otherwise). */
untracked: string | undefined
/** Re-read the diff (and drop the fold cache) with the next tick. */
refresh(): void
/** The on-demand fold loader `DiffFiles` forwards to its rows (undefined without a ref). */
resolveFold: ((file: DiffFile, segment: FoldSegment) => Promise<readonly DiffRow[]>) | undefined
}
/**
* Load one git diff target (worktree change or commit patch) and expose its
* on-demand fold expansion. `ref === null` keeps every value inert.
*/
export function useGitDiffTarget(ref: SidebarDiffRef | null, scope: SessionScope): GitDiffTarget {
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(ref !== null)
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
// Granular scope fields: the scope object's identity churns, only these
// three gate the target (the same rule the sibling call sites used).
const sessionId = scope.sessionId
const cwd = scope.cwd
const repoRoot = ref?.repoRoot
const gitScope = useMemo<SessionScope>(
() => ({ sessionId, cwd, ...(repoRoot !== undefined ? { repoRoot } : {}) }),
[sessionId, cwd, repoRoot],
)
useEffect(() => {
if (ref === null) return
let cancelled = false
const paneScope: SessionScope = { sessionId, cwd, ...(ref.repoRoot !== undefined ? { repoRoot: ref.repoRoot } : {}) }
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (ref.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, ref.hashFull, ref.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, ref.path, ref.staged, ref.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, ref.path, !ref.staged, ref.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!ref.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content. A child-repo path is
// relative to ref.repoRoot, not the session cwd or the linked-worktree
// root, so the read resolves against whichever the ref carries.
if (ref.untracked === true && !ref.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(ref.repoRoot ?? ref.worktree ?? cwd, ref.path))
if (!cancelled) {
setDiffText('')
if (text.kind === 'text') setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [ref, sessionId, cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ────────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [ref, tick])
const resolveFold = useMemo(() => {
if (ref === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (ref.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${ref.hashFull}^`, displayPath(file.oldPath), ref.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ref.hashFull, displayPath(file.newPath), ref.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? ref.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), ref.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), ref.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), ref.worktree),
api.fsRead(gitScope, resolveSidebarPath(ref.repoRoot ?? ref.worktree ?? cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [ref, gitScope, effectiveStaged, cwd])
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
return { loading, error, diffText, untracked, refresh, resolveFold }
}
+10 -2
View File
@@ -37,8 +37,16 @@ import './layout.css'
* (rc.8+) is the client module system the chunk loader resolves its
* externals through; `connection` (0.1.2-alpha.2+) is the Remote transport's
* recovery lifecycle the side chat's disconnect banner reads — Cordis guards
* service access without inject. */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection']
* service access without inject.
*
* `remote` / `remote.session` (0.1.7) are the Host Remote namespace the
* "open in app" adapter calls (`canOpenWorkspacePath` /
* `workspacePathApplications` / `openWorkspacePath`). BOTH entries are
* required: Cordis validates the nested property access
* (`ctx.get('remote').session`) against the inject list, and a missing
* entry throws `cannot get property "remote.session" without inject` —
* which, thrown from a render path, blanks the whole sidebar panel. */
export const inject = ['slots', 'sessions', 'locale', 'modules', 'connection', 'remote', 'remote.session']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
-61
View File
@@ -78,64 +78,3 @@ 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;
}
+25 -21
View File
@@ -58,26 +58,31 @@ export const ar: Record<string, string> = {
editorEmptyHint: 'اختر ملفاً من لوحة الشجرة أو من حقل المسار بالأعلى لبدء المعاينة',
openFileNewTab: 'فتح في تبويب جديد',
openFileSide: 'فتح إلى الجانب',
openWithMenu: 'فتح بواسطة',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'تثبيت في القائمة',
unpinOpenWith: 'إلغاء التثبيت',
openWithExplorer: 'مدير الملفات',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'مضيف SSH البعيد',
openWithSettingsSshDesc: 'فارغ = مساحة عمل محلية؛ مع user@host أو اسم مستعار SSH، تتحوّل فواتح عائلة VSCode إلى بروتوكول vscode-remote/ssh-remote ويُخفى مدير الملفات وZed والمحرّرات المخصّصة غير التابعة لعائلة VSCode من القائمة',
openWithSettingsSshPlaceholder: 'user@host أو اسم مستعار SSH',
openWithSettingsCustomTitle: 'محرّرات مخصّصة',
openWithSettingsCustomDesc: 'اسم + قالب URL (عنصر نائب {path}) + علامة عائلة VSCode؛ في الوضع البعيد، يمكن فقط لمحرّرات عائلة VSCode فتح مسار بعيد',
openWithSettingsAdd: 'إضافة',
openWithSettingsName: 'الاسم',
openWithSettingsTemplate: 'مثل cursor://file/{path}',
openWithSettingsFamily: 'عائلة VSCode',
openWithSettingsFamilyDesc: 'يتحدّث هذا المحرّر لهجة URL لـ VSCode (يدعم الفتح عبر SSH البعيد)',
openWithSettingsRemove: 'إزالة',
openWithSettingsInvalidHint: 'لن تظهر المحرّرات التي تفتقر إلى الاسم أو القالب (يلزم أن يحتوي على {path} ويبدأ بـ scheme://) في القائمة',
openInApp: 'فتح باستخدام',
openInAppDefault: 'فتح بالتطبيق الافتراضي',
openInAppEmpty: 'لا يوجد تطبيق يمكنه فتحه',
openInAppFailed: 'تعذّر الفتح: {path}',
revealInFileManager: 'إظهار في مدير الملفات',
filesSelected: 'تم تحديد {count}',
clearSelection: 'إلغاء التحديد',
copyPaths: 'نسخ المسارات',
deleteSelected: 'حذف المحدد',
deleteSelectedTitle: 'حذف {count} عنصراً محدداً؟',
deleteSelectedDesc: 'ستُحذف هذه الملفات والمجلدات نهائياً ولا يمكن استرجاعها.',
newFolder: 'مجلد جديد',
newFolderPlaceholder: 'اسم المجلد',
newFolderInvalid: 'اسم المجلد غير صالح',
filesTruncated: 'يحتوي هذا المجلد على عناصر كثيرة جداً؛ تُعرض العناصر الأولى فقط',
gitStatusModified: 'معدّل',
gitStatusAdded: 'مضاف',
gitStatusDeleted: 'محذوف',
gitStatusUntracked: 'غير متتبَّع',
gitStatusRenamed: 'أُعيدت تسميته',
gitStatusConflict: 'تعارض',
changesClean: 'لا توجد تغييرات في مساحة العمل',
changesNoHistory: 'لا يوجد سجل إيداعات بعد',
changesStageFailed: 'فشل الإدراج: {message}',
changesOpGone: 'لم تعد هذه العملية في سجل الجلسة',
newTab: 'تبويب جديد',
brokenSymlink: 'رابط رمزي معطوب',
close: 'إغلاق',
@@ -121,7 +126,6 @@ export const ar: Record<string, string> = {
error: 'فشل التحميل',
retry: 'إعادة المحاولة',
notRepo: 'هذا الدليل ليس مستودع git',
noChanges: 'لا تغييرات',
statusTruncated: 'التغييرات كثيرة جدًا؛ يتم عرض أول 2000 إدخال فقط',
stage: 'إدراج',
unstage: 'إلغاء الإدراج',
+25 -21
View File
@@ -43,26 +43,31 @@ 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',
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ü',
openInApp: 'Öffnen mit',
openInAppDefault: 'Mit Standard-App öffnen',
openInAppEmpty: 'Keine App kann diese Datei öffnen',
openInAppFailed: 'Öffnen fehlgeschlagen: {path}',
revealInFileManager: 'Im Dateimanager anzeigen',
filesSelected: '{count} ausgewählt',
clearSelection: 'Auswahl aufheben',
copyPaths: 'Pfade kopieren',
deleteSelected: 'Auswahl löschen',
deleteSelectedTitle: '{count} ausgewählte Elemente löschen?',
deleteSelectedDesc: 'Diese Dateien und Ordner werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.',
newFolder: 'Neuer Ordner',
newFolderPlaceholder: 'Ordnername',
newFolderInvalid: 'Ungültiger Ordnername',
filesTruncated: 'Dieser Ordner enthält zu viele Einträge; nur die ersten werden angezeigt',
gitStatusModified: 'Geändert',
gitStatusAdded: 'Hinzugefügt',
gitStatusDeleted: 'Gelöscht',
gitStatusUntracked: 'Nicht verfolgt',
gitStatusRenamed: 'Umbenannt',
gitStatusConflict: 'Konflikt',
changesClean: 'Keine Änderungen im Arbeitsbereich',
changesNoHistory: 'Noch keine Commit-Historie',
changesStageFailed: 'Staging fehlgeschlagen: {message}',
changesOpGone: 'Dieser Vorgang ist nicht mehr im Sitzungsprotokoll',
newTab: 'Neuer Tab',
brokenSymlink: 'Defekter Symlink',
close: 'Schließen',
@@ -106,7 +111,6 @@ export const de: Record<string, string> = {
error: 'Laden fehlgeschlagen',
retry: 'Erneut versuchen',
notRepo: 'Dieses Verzeichnis ist kein Git-Repository',
noChanges: 'Keine Änderungen',
statusTruncated: 'Zu viele Änderungen; nur die ersten 2000 Einträge werden angezeigt',
stage: 'Stagen',
unstage: 'Unstagen',
+25 -21
View File
@@ -50,26 +50,31 @@ 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é',
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',
openInApp: 'Ouvrir avec',
openInAppDefault: 'Ouvrir avec l’application par défaut',
openInAppEmpty: 'Aucune application ne peut l’ouvrir',
openInAppFailed: 'Impossible d’ouvrir : {path}',
revealInFileManager: 'Afficher dans le gestionnaire de fichiers',
filesSelected: '{count} élément(s) sélectionné(s)',
clearSelection: 'Effacer la sélection',
copyPaths: 'Copier les chemins',
deleteSelected: 'Supprimer la sélection',
deleteSelectedTitle: 'Supprimer {count} élément(s) sélectionné(s) ?',
deleteSelectedDesc: 'Ces fichiers et dossiers seront définitivement supprimés. Cette action est irréversible.',
newFolder: 'Nouveau dossier',
newFolderPlaceholder: 'Nom du dossier',
newFolderInvalid: 'Nom de dossier invalide',
filesTruncated: 'Ce dossier contient trop d’éléments ; seuls les premiers sont affichés',
gitStatusModified: 'Modifié',
gitStatusAdded: 'Ajouté',
gitStatusDeleted: 'Supprimé',
gitStatusUntracked: 'Non suivi',
gitStatusRenamed: 'Renommé',
gitStatusConflict: 'En conflit',
changesClean: 'Aucune modification dans le workspace',
changesNoHistory: 'Aucun commit pour l’instant',
changesStageFailed: 'Échec de l’indexation : {message}',
changesOpGone: 'Cette opération n’est plus dans le journal de la session',
newTab: 'Nouvel onglet',
brokenSymlink: 'Lien symbolique cassé',
close: 'Fermer',
@@ -113,7 +118,6 @@ export const fr: Record<string, string> = {
error: 'Échec du chargement',
retry: 'Réessayer',
notRepo: 'Ce répertoire n’est pas un dépôt git',
noChanges: 'Aucune modification',
statusTruncated: 'Trop de modifications ; seules les 2000 premières entrées sont affichées',
stage: 'Mettre en scène',
unstage: 'Annuler la mise en scène',
+25 -21
View File
@@ -57,26 +57,31 @@ export const hi: Record<string, string> = {
editorEmptyHint: 'पूर्वावलोकन शुरू करने के लिए दाएँ फ़ाइल ट्री या ऊपर पथ इनपुट से फ़ाइल चुनें',
openFileNewTab: 'नए टैब में खोलें',
openFileSide: 'बाजू में खोलें',
openWithMenu: 'इसके साथ खोलें',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'मेन्यू पर पिन करें',
unpinOpenWith: 'पिन हटाएं',
openWithExplorer: 'फ़ाइल मैनेजर',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH रिमोट होस्ट',
openWithSettingsSshDesc: 'खाली = स्थानीय वर्कस्पेस; user@host या SSH उपनाम के साथ, VSCode-परिवार ओपनर vscode-remote/ssh-remote प्रोटोकॉल पर स्विच हो जाते हैं और फ़ाइल मैनेजर / Zed / ग़ैर-VSCode-परिवार कस्टम एडिटर मेन्यू से छिप जाते हैं',
openWithSettingsSshPlaceholder: 'user@host या SSH उपनाम',
openWithSettingsCustomTitle: 'कस्टम एडिटर',
openWithSettingsCustomDesc: 'नाम + URL टेम्पलेट ({path} प्लेसहोल्डर) + VSCode-परिवार फ़्लैग; रिमोट मोड में केवल VSCode-परिवार एडिटर रिमोट पथ खोल सकते हैं',
openWithSettingsAdd: 'जोड़ें',
openWithSettingsName: 'नाम',
openWithSettingsTemplate: 'जैसे cursor://file/{path}',
openWithSettingsFamily: 'VSCode-परिवार',
openWithSettingsFamilyDesc: 'यह एडिटर VSCode URL बोली बोलता है (SSH-रिमोट खोलने का समर्थन)',
openWithSettingsRemove: 'हटाएं',
openWithSettingsInvalidHint: 'नाम या {path} / scheme:// के बिना टेम्पलेट वाले एडिटर मेन्यू में नहीं दिखते',
openInApp: 'इसके साथ खोलें',
openInAppDefault: 'डिफ़ॉल्ट ऐप से खोलें',
openInAppEmpty: 'इसे खोलने के लिए कोई ऐप नहीं',
openInAppFailed: 'खोल नहीं सका: {path}',
revealInFileManager: 'फ़ाइल मैनेजर में दिखाएँ',
filesSelected: '{count} चयनित',
clearSelection: 'चयन हटाएँ',
copyPaths: 'पथ कॉपी करें',
deleteSelected: 'चयनित हटाएँ',
deleteSelectedTitle: 'चयनित {count} आइटम हटाएँ?',
deleteSelectedDesc: 'ये फ़ाइलें और फ़ोल्डर हमेशा के लिए हटा दिए जाएँगे; इन्हें वापस नहीं लाया जा सकता।',
newFolder: 'नया फ़ोल्डर',
newFolderPlaceholder: 'फ़ोल्डर नाम',
newFolderInvalid: 'फ़ोल्डर नाम अमान्य है',
filesTruncated: 'इस फ़ोल्डर में बहुत अधिक प्रविष्टियाँ हैं; केवल शुरुआती कुछ दिखाई जा रही हैं',
gitStatusModified: 'संशोधित',
gitStatusAdded: 'जोड़ा गया',
gitStatusDeleted: 'हटाया गया',
gitStatusUntracked: 'अनट्रैक्ड',
gitStatusRenamed: 'नाम बदला गया',
gitStatusConflict: 'टकराव',
changesClean: 'वर्कस्पेस में कोई बदलाव नहीं',
changesNoHistory: 'अभी कोई कमिट इतिहास नहीं',
changesStageFailed: 'स्टेज विफल: {message}',
changesOpGone: 'यह कार्रवाई अब सत्र लॉग में नहीं है',
newTab: 'नया टैब',
brokenSymlink: 'टूटा सिमलिंक',
close: 'बंद करें',
@@ -120,7 +125,6 @@ export const hi: Record<string, string> = {
error: 'लोड विफल',
retry: 'पुनः प्रयास',
notRepo: 'यह डायरेक्टरी एक git रिपॉज़िटरी नहीं है',
noChanges: 'कोई बदलाव नहीं',
statusTruncated: 'बहुत अधिक बदलाव; केवल पहली 2000 प्रविष्टियाँ दिखाई जा रही हैं',
stage: 'स्टेज',
unstage: 'अनस्टेज',
+25 -21
View File
@@ -55,26 +55,31 @@ 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',
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',
openInApp: 'Buka dengan',
openInAppDefault: 'Buka dengan aplikasi default',
openInAppEmpty: 'Tidak ada aplikasi yang bisa membukanya',
openInAppFailed: 'Gagal membuka: {path}',
revealInFileManager: 'Tampilkan di pengelola berkas',
filesSelected: '{count} dipilih',
clearSelection: 'Hapus pilihan',
copyPaths: 'Salin path',
deleteSelected: 'Hapus yang dipilih',
deleteSelectedTitle: 'Hapus {count} item yang dipilih?',
deleteSelectedDesc: 'Berkas dan folder ini akan dihapus permanen dan tidak bisa dikembalikan.',
newFolder: 'Folder baru',
newFolderPlaceholder: 'Nama folder',
newFolderInvalid: 'Nama folder tidak valid',
filesTruncated: 'Folder ini berisi terlalu banyak entri; hanya sebagian awal yang ditampilkan',
gitStatusModified: 'Dimodifikasi',
gitStatusAdded: 'Ditambahkan',
gitStatusDeleted: 'Dihapus',
gitStatusUntracked: 'Tidak dilacak',
gitStatusRenamed: 'Nama diubah',
gitStatusConflict: 'Konflik',
changesClean: 'Tidak ada perubahan di workspace',
changesNoHistory: 'Belum ada riwayat commit',
changesStageFailed: 'Gagal melakukan stage: {message}',
changesOpGone: 'Operasi ini tidak lagi ada di log sesi',
newTab: 'Tab baru',
brokenSymlink: 'Symlink rusak',
close: 'Tutup',
@@ -118,7 +123,6 @@ export const id: Record<string, string> = {
error: 'Gagal memuat',
retry: 'Coba lagi',
notRepo: 'Direktori ini bukan repositori git',
noChanges: 'Tidak ada perubahan',
statusTruncated: 'Terlalu banyak perubahan; hanya 2000 entri pertama yang ditampilkan',
stage: 'Stage',
unstage: 'Unstage',
+25 -21
View File
@@ -48,26 +48,31 @@ 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',
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',
openInApp: 'Apri con',
openInAppDefault: 'Apri con l’app predefinita',
openInAppEmpty: 'Nessuna applicazione può aprirlo',
openInAppFailed: 'Impossibile aprire: {path}',
revealInFileManager: 'Mostra nel gestore file',
filesSelected: '{count} selezionati',
clearSelection: 'Annulla selezione',
copyPaths: 'Copia percorsi',
deleteSelected: 'Elimina selezione',
deleteSelectedTitle: 'Eliminare {count} elementi selezionati?',
deleteSelectedDesc: 'Questi file e cartelle saranno eliminati definitivamente. L’operazione non può essere annullata.',
newFolder: 'Nuova cartella',
newFolderPlaceholder: 'Nome cartella',
newFolderInvalid: 'Nome cartella non valido',
filesTruncated: 'Questa cartella contiene troppe voci; vengono mostrate solo le prime',
gitStatusModified: 'Modificato',
gitStatusAdded: 'Aggiunto',
gitStatusDeleted: 'Eliminato',
gitStatusUntracked: 'Non tracciato',
gitStatusRenamed: 'Rinominato',
gitStatusConflict: 'In conflitto',
changesClean: 'Nessuna modifica nel workspace',
changesNoHistory: 'Nessun commit per ora',
changesStageFailed: 'Staging non riuscito: {message}',
changesOpGone: 'Questa operazione non è più nel registro della sessione',
newTab: 'Nuova scheda',
brokenSymlink: 'Collegamento simbolico interrotto',
close: 'Chiudi',
@@ -111,7 +116,6 @@ export const it: Record<string, string> = {
error: 'Caricamento non riuscito',
retry: 'Riprova',
notRepo: 'Questa directory non è un repository git',
noChanges: 'Nessuna modifica',
statusTruncated: 'Troppe modifiche; vengono mostrate solo le prime 2000 voci',
stage: 'Prepara',
unstage: 'Rimuovi dalla preparazione',
+25 -21
View File
@@ -57,26 +57,31 @@ export const ja: Record<string, string> = {
editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー',
openFileNewTab: '新しいタブで開く',
openFileSide: '横に開く',
openWithMenu: 'アプリで開く',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'メニューに固定',
unpinOpenWith: '固定を解除',
openWithExplorer: 'エクスプローラー',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH リモートホスト',
openWithSettingsSshDesc: '空欄 = ローカルワークスペース。user@host または SSH エイリアスを入力すると、VSCode 系の起動方法は vscode-remote/ssh-remote プロトコルに切り替わり、エクスプローラー / Zed / 非 VSCode 系カスタムエディターはメニューから非表示になります',
openWithSettingsSshPlaceholder: 'user@host または SSH エイリアス',
openWithSettingsCustomTitle: 'カスタムエディター',
openWithSettingsCustomDesc: '名前 + URL テンプレート({path} プレースホルダー)+ VSCode 系かどうか。SSH モードでは VSCode 系のみリモートパスを開けます',
openWithSettingsAdd: '追加',
openWithSettingsName: '名前',
openWithSettingsTemplate: '例: cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: 'このエディターは VSCode の URL プロトコルを使います(SSH リモートオープンをサポート)',
openWithSettingsRemove: '削除',
openWithSettingsInvalidHint: '名前またはテンプレート({path} を含み scheme:// で始まる必要あり)が未入力の編集者はメニューに表示されません',
openInApp: 'アプリで開く',
openInAppDefault: '既定のアプリで開く',
openInAppEmpty: '開けるアプリがありません',
openInAppFailed: '開けませんでした:{path}',
revealInFileManager: 'ファイルマネージャーで表示',
filesSelected: '{count} 件を選択中',
clearSelection: '選択を解除',
copyPaths: 'パスをコピー',
deleteSelected: '選択項目を削除',
deleteSelectedTitle: '選択した {count} 件を削除しますか?',
deleteSelectedDesc: 'これらのファイルとフォルダーは完全に削除され、元に戻せません。',
newFolder: '新しいフォルダー',
newFolderPlaceholder: 'フォルダー名',
newFolderInvalid: 'フォルダー名が無効です',
filesTruncated: 'このフォルダーの項目が多すぎるため、先頭の一部のみ表示しています',
gitStatusModified: '変更あり',
gitStatusAdded: '追加',
gitStatusDeleted: '削除',
gitStatusUntracked: '未追跡',
gitStatusRenamed: '名前変更',
gitStatusConflict: '競合',
changesClean: 'ワークスペースに変更はありません',
changesNoHistory: 'コミット履歴がまだありません',
changesStageFailed: 'ステージに失敗しました:{message}',
changesOpGone: 'この操作はセッションログに残っていません',
newTab: '新しいタブ',
brokenSymlink: '無効なシンボリックリンク',
close: '閉じる',
@@ -120,7 +125,6 @@ export const ja: Record<string, string> = {
error: '読み込みに失敗',
retry: '再試行',
notRepo: 'このディレクトリは git リポジトリではありません',
noChanges: '変更はありません',
statusTruncated: '変更が多すぎるため、最初の 2000 件のみ表示しています',
stage: 'ステージ',
unstage: 'ステージ解除',
+25 -21
View File
@@ -49,26 +49,31 @@ export const ko: Record<string, string> = {
editorEmptyHint: '오른쪽 파일 트리나 위쪽 경로 입력란에서 파일을 선택해 미리보기를 시작하세요',
openFileNewTab: '새 Tab으로 열기',
openFileSide: '측면에 열기',
openWithMenu: '앱에서 열기',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '메뉴에 고정',
unpinOpenWith: '고정 해제',
openWithExplorer: '파일 탐색기',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 원격 호스트',
openWithSettingsSshDesc: '비워두면 로컬 작업 공간입니다. user@host 또는 SSH 별칭을 입력하면 VSCode 계열 열기 방식이 vscode-remote/ssh-remote 프로토콜을 사용하게 되며, 파일 탐색기 / Zed / 비 VSCode 계열 사용자 지정 편집기는 메뉴에서 숨겨집니다',
openWithSettingsSshPlaceholder: 'user@host 또는 SSH 별칭',
openWithSettingsCustomTitle: '사용자 지정 편집기',
openWithSettingsCustomDesc: '이름 + URL 템플릿({path} 자리 표시자) + VSCode 계열 여부. SSH 모드에서는 VSCode 계열만 원격을 열 수 있습니다',
openWithSettingsAdd: '추가',
openWithSettingsName: '이름',
openWithSettingsTemplate: '예: cursor://file/{path}',
openWithSettingsFamily: 'VSCode 계열',
openWithSettingsFamilyDesc: '이 편집기는 VSCode의 URL 프로토콜을 사용합니다(SSH 원격 열기 지원)',
openWithSettingsRemove: '삭제',
openWithSettingsInvalidHint: '이름이나 템플릿({path} 포함, scheme:// 로 시작해야 함)을 입력하지 않은 편집기는 메뉴에 표시되지 않습니다',
openInApp: '앱으로 열기',
openInAppDefault: '기본 앱으로 열기',
openInAppEmpty: '이 파일을 열 수 있는 앱이 없습니다',
openInAppFailed: '열 수 없습니다: {path}',
revealInFileManager: '파일 관리자에서 표시',
filesSelected: '{count}개 선택됨',
clearSelection: '선택 해제',
copyPaths: '경로 복사',
deleteSelected: '선택 항목 삭제',
deleteSelectedTitle: '선택한 {count}개 항목을 삭제할까요?',
deleteSelectedDesc: '이 파일과 폴더는 영구히 삭제되며 되돌릴 수 없습니다.',
newFolder: '새 폴더',
newFolderPlaceholder: '폴더 이름',
newFolderInvalid: '폴더 이름이 올바르지 않습니다',
filesTruncated: '이 폴더에 항목이 너무 많아 앞부분만 표시합니다',
gitStatusModified: '수정됨',
gitStatusAdded: '추가됨',
gitStatusDeleted: '삭제됨',
gitStatusUntracked: '추적 안 됨',
gitStatusRenamed: '이름 변경됨',
gitStatusConflict: '충돌',
changesClean: '워크스페이스에 변경 사항이 없습니다',
changesNoHistory: '아직 커밋 기록이 없습니다',
changesStageFailed: '스테이징 실패: {message}',
changesOpGone: '이 작업은 더 이상 세션 로그에 없습니다',
newTab: '새 탭',
brokenSymlink: '깨진 심볼릭 링크',
close: '닫기',
@@ -112,7 +117,6 @@ export const ko: Record<string, string> = {
error: '불러오기 실패',
retry: '다시 시도',
notRepo: '현재 디렉터리는 git 저장소가 아닙니다',
noChanges: '변경 사항 없음',
statusTruncated: '변경 사항이 너무 많아 처음 2000개만 표시합니다',
stage: '스테이징',
unstage: '스테이징 해제',
+25 -21
View File
@@ -55,26 +55,31 @@ 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',
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',
openInApp: 'Openen met',
openInAppDefault: 'Openen met standaardapp',
openInAppEmpty: 'Geen app kan dit openen',
openInAppFailed: 'Kan niet openen: {path}',
revealInFileManager: 'Tonen in bestandsbeheerder',
filesSelected: '{count} geselecteerd',
clearSelection: 'Selectie wissen',
copyPaths: 'Paden kopiëren',
deleteSelected: 'Selectie verwijderen',
deleteSelectedTitle: '{count} geselecteerde items verwijderen?',
deleteSelectedDesc: 'Deze bestanden en mappen worden permanent verwijderd. Dit kan niet ongedaan worden gemaakt.',
newFolder: 'Nieuwe map',
newFolderPlaceholder: 'Mapnaam',
newFolderInvalid: 'Ongeldige mapnaam',
filesTruncated: 'Deze map bevat te veel items; alleen de eerste worden getoond',
gitStatusModified: 'Gewijzigd',
gitStatusAdded: 'Toegevoegd',
gitStatusDeleted: 'Verwijderd',
gitStatusUntracked: 'Niet gevolgd',
gitStatusRenamed: 'Hernoemd',
gitStatusConflict: 'Conflict',
changesClean: 'Geen wijzigingen in de workspace',
changesNoHistory: 'Nog geen commitgeschiedenis',
changesStageFailed: 'Stagen mislukt: {message}',
changesOpGone: 'Deze bewerking staat niet meer in het sessielogboek',
newTab: 'Nieuw tabblad',
brokenSymlink: 'Gebroken symlink',
close: 'Sluiten',
@@ -118,7 +123,6 @@ export const nl: Record<string, string> = {
error: 'Laden mislukt',
retry: 'Opnieuw proberen',
notRepo: 'Deze map is geen git-repository',
noChanges: 'Geen wijzigingen',
statusTruncated: 'Te veel wijzigingen; alleen de eerste 2000 items worden weergegeven',
stage: 'Stagen',
unstage: 'Unstagen',
+25 -21
View File
@@ -59,26 +59,31 @@ 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',
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',
openInApp: 'Otwórz za pomocą',
openInAppDefault: 'Otwórz w domyślnej aplikacji',
openInAppEmpty: 'Brak aplikacji, która może to otworzyć',
openInAppFailed: 'Nie udało się otworzyć: {path}',
revealInFileManager: 'Pokaż w menedżerze plików',
filesSelected: 'Wybrano: {count}',
clearSelection: 'Wyczyść wybór',
copyPaths: 'Kopiuj ścieżki',
deleteSelected: 'Usuń wybrane',
deleteSelectedTitle: 'Usunąć {count} wybranych elementów?',
deleteSelectedDesc: 'Te pliki i foldery zostaną trwale usunięte. Tej operacji nie można cofnąć.',
newFolder: 'Nowy folder',
newFolderPlaceholder: 'Nazwa folderu',
newFolderInvalid: 'Nieprawidłowa nazwa folderu',
filesTruncated: 'Ten folder zawiera zbyt wiele pozycji; wyświetlane są tylko pierwsze',
gitStatusModified: 'Zmodyfikowany',
gitStatusAdded: 'Dodany',
gitStatusDeleted: 'Usunięty',
gitStatusUntracked: 'Nieśledzony',
gitStatusRenamed: 'Zmieniono nazwę',
gitStatusConflict: 'Konflikt',
changesClean: 'Brak zmian w obszarze roboczym',
changesNoHistory: 'Brak historii commitów',
changesStageFailed: 'Nie udało się wykonać stage: {message}',
changesOpGone: 'Tej operacji nie ma już w dzienniku sesji',
newTab: 'Nowa karta',
brokenSymlink: 'Uszkodzone dowiązanie symboliczne',
close: 'Zamknij',
@@ -122,7 +127,6 @@ export const pl: Record<string, string> = {
error: 'Ładowanie nie powiodło się',
retry: 'Ponów',
notRepo: 'Ten katalog nie jest repozytorium git',
noChanges: 'Brak zmian',
statusTruncated: 'Zbyt wiele zmian; pokazywane jest tylko pierwsze 2000 wpisów',
stage: 'Dodaj do indeksu',
unstage: 'Usuń z indeksu',
+25 -21
View File
@@ -40,26 +40,31 @@ 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',
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',
openInApp: 'Abrir com',
openInAppDefault: 'Abrir com o aplicativo padrão',
openInAppEmpty: 'Nenhum aplicativo pode abri-lo',
openInAppFailed: 'Não foi possível abrir: {path}',
revealInFileManager: 'Mostrar no gerenciador de arquivos',
filesSelected: '{count} selecionados',
clearSelection: 'Limpar seleção',
copyPaths: 'Copiar caminhos',
deleteSelected: 'Excluir selecionados',
deleteSelectedTitle: 'Excluir {count} itens selecionados?',
deleteSelectedDesc: 'Estes arquivos e pastas serão excluídos permanentemente. Não será possível desfazer.',
newFolder: 'Nova pasta',
newFolderPlaceholder: 'Nome da pasta',
newFolderInvalid: 'Nome de pasta inválido',
filesTruncated: 'Esta pasta tem entradas demais; apenas as primeiras são exibidas',
gitStatusModified: 'Modificado',
gitStatusAdded: 'Adicionado',
gitStatusDeleted: 'Excluído',
gitStatusUntracked: 'Não rastreado',
gitStatusRenamed: 'Renomeado',
gitStatusConflict: 'Em conflito',
changesClean: 'Nenhuma alteração no workspace',
changesNoHistory: 'Ainda não há histórico de commits',
changesStageFailed: 'Falha ao preparar: {message}',
changesOpGone: 'Esta operação não está mais no registro da sessão',
newTab: 'Nova aba',
brokenSymlink: 'Symlink quebrado',
close: 'Fechar',
@@ -103,7 +108,6 @@ export const pt: Record<string, string> = {
error: 'Falha ao carregar',
retry: 'Tentar novamente',
notRepo: 'Este diretório não é um repositório git',
noChanges: 'Sem alterações',
statusTruncated: 'Muitas alterações; exibindo apenas as primeiras 2000 entradas',
stage: 'Preparar (stage)',
unstage: 'Desfazer stage',
+25 -21
View File
@@ -53,26 +53,31 @@ export const ru: Record<string, string> = {
editorEmptyHint: 'Выберите файл из дерева справа или введите путь выше, чтобы начать предпросмотр',
openFileNewTab: 'Открыть в новой вкладке',
openFileSide: 'Открыть сбоку',
openWithMenu: 'Открыть в приложении',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Закрепить в меню',
unpinOpenWith: 'Открепить',
openWithExplorer: 'Проводник',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'Удалённый хост SSH',
openWithSettingsSshDesc: 'Пусто = локальная рабочая область; при указании user@host или псевдонима SSH способы открытия семейства VSCode переключаются на протокол vscode-remote/ssh-remote, а проводник / Zed / сторонние редакторы вне семейства VSCode скрываются из меню',
openWithSettingsSshPlaceholder: 'user@host или псевдоним SSH',
openWithSettingsCustomTitle: 'Собственные редакторы',
openWithSettingsCustomDesc: 'Имя + шаблон URL (плейсхолдер {path}) + признак семейства VSCode; в режиме SSH только редакторы семейства VSCode могут открывать удалённые пути',
openWithSettingsAdd: 'Добавить',
openWithSettingsName: 'Имя',
openWithSettingsTemplate: 'напр. cursor://file/{path}',
openWithSettingsFamily: 'Семейство VSCode',
openWithSettingsFamilyDesc: 'Этот редактор использует URL-протокол VSCode (поддерживает удалённое открытие по SSH)',
openWithSettingsRemove: 'Удалить',
openWithSettingsInvalidHint: 'Редакторы без имени или с шаблоном без {path} / scheme:// не появятся в меню',
openInApp: 'Открыть с помощью',
openInAppDefault: 'Открыть приложением по умолчанию',
openInAppEmpty: 'Нет приложения, которое может это открыть',
openInAppFailed: 'Не удалось открыть: {path}',
revealInFileManager: 'Показать в файловом менеджере',
filesSelected: 'Выбрано: {count}',
clearSelection: 'Снять выделение',
copyPaths: 'Копировать пути',
deleteSelected: 'Удалить выбранное',
deleteSelectedTitle: 'Удалить выбранные элементы ({count})?',
deleteSelectedDesc: 'Эти файлы и папки будут удалены безвозвратно. Отменить это действие нельзя.',
newFolder: 'Новая папка',
newFolderPlaceholder: 'Имя папки',
newFolderInvalid: 'Недопустимое имя папки',
filesTruncated: 'В этой папке слишком много элементов; показаны только первые',
gitStatusModified: 'Изменён',
gitStatusAdded: 'Добавлен',
gitStatusDeleted: 'Удалён',
gitStatusUntracked: 'Не отслеживается',
gitStatusRenamed: 'Переименован',
gitStatusConflict: 'Конфликт',
changesClean: 'В рабочей области нет изменений',
changesNoHistory: 'Истории коммитов пока нет',
changesStageFailed: 'Ошибка индексирования: {message}',
changesOpGone: 'Этой операции больше нет в журнале сессии',
newTab: 'Новая вкладка',
brokenSymlink: 'Сломанная символическая ссылка',
close: 'Закрыть',
@@ -116,7 +121,6 @@ export const ru: Record<string, string> = {
error: 'Ошибка загрузки',
retry: 'Повторить',
notRepo: 'Этот каталог не является git-репозиторием',
noChanges: 'Нет изменений',
statusTruncated: 'Слишком много изменений; показаны только первые 2000 записей',
stage: 'Индексировать',
unstage: 'Убрать из индекса',
+25 -21
View File
@@ -40,26 +40,31 @@ 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',
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',
openInApp: 'Öppna med',
openInAppDefault: 'Öppna med standardapp',
openInAppEmpty: 'Ingen app kan öppna den',
openInAppFailed: 'Kunde inte öppna: {path}',
revealInFileManager: 'Visa i filhanteraren',
filesSelected: '{count} markerade',
clearSelection: 'Rensa markering',
copyPaths: 'Kopiera sökvägar',
deleteSelected: 'Ta bort markerade',
deleteSelectedTitle: 'Ta bort {count} markerade objekt?',
deleteSelectedDesc: 'Filerna och mapparna tas bort permanent. Det går inte att ångra.',
newFolder: 'Ny mapp',
newFolderPlaceholder: 'Mappnamn',
newFolderInvalid: 'Ogiltigt mappnamn',
filesTruncated: 'Mappen har för många poster; bara de första visas',
gitStatusModified: 'Ändrad',
gitStatusAdded: 'Tillagd',
gitStatusDeleted: 'Borttagen',
gitStatusUntracked: 'Spåras inte',
gitStatusRenamed: 'Omdöpt',
gitStatusConflict: 'Konflikt',
changesClean: 'Inga ändringar i arbetsytan',
changesNoHistory: 'Ingen commit-historik ännu',
changesStageFailed: 'Kunde inte köa: {message}',
changesOpGone: 'Åtgärden finns inte längre i sessionsloggen',
newTab: 'Ny flik',
brokenSymlink: 'Bruten symbolisk länk',
close: 'Stäng',
@@ -103,7 +108,6 @@ export const sv: Record<string, string> = {
error: 'Kunde inte ladda',
retry: 'Försök igen',
notRepo: 'Denna katalog är inte ett Git-arkiv',
noChanges: 'Inga ändringar',
statusTruncated: 'För många ändringar; endast de första 2000 posterna visas',
stage: 'Köa',
unstage: 'Avköa',
+25 -21
View File
@@ -57,26 +57,31 @@ export const th: Record<string, string> = {
editorEmptyHint: 'เลือกไฟล์จากพาเนลต้นไม้ไฟล์หรือช่องกรอกพาธด้านบนเพื่อเริ่มพรีวิว',
openFileNewTab: 'เปิดใน Tab ใหม่',
openFileSide: 'เปิดด้านข้าง',
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:// จะไม่ปรากฏในเมนู',
openInApp: 'เปิดด้วย',
openInAppDefault: 'เปิดด้วยแอปเริ่มต้น',
openInAppEmpty: 'ไม่มีแอปที่เปิดไฟล์นี้ได้',
openInAppFailed: 'เปิดไม่สำเร็จ: {path}',
revealInFileManager: 'แสดงในตัวจัดการไฟล์',
filesSelected: 'เลือกแล้ว {count} รายการ',
clearSelection: 'ล้างการเลือก',
copyPaths: 'คัดลอกพาธ',
deleteSelected: 'ลบรายการที่เลือก',
deleteSelectedTitle: 'ลบ {count} รายการที่เลือกหรือไม่',
deleteSelectedDesc: 'ไฟล์และโฟลเดอร์เหล่านี้จะถูกลบอย่างถาวรและกู้คืนไม่ได้',
newFolder: 'โฟลเดอร์ใหม่',
newFolderPlaceholder: 'ชื่อโฟลเดอร์',
newFolderInvalid: 'ชื่อโฟลเดอร์ไม่ถูกต้อง',
filesTruncated: 'โฟลเดอร์นี้มีรายการมากเกินไป จึงแสดงเฉพาะส่วนแรก',
gitStatusModified: 'แก้ไขแล้ว',
gitStatusAdded: 'เพิ่มแล้ว',
gitStatusDeleted: 'ลบแล้ว',
gitStatusUntracked: 'ยังไม่ติดตาม',
gitStatusRenamed: 'เปลี่ยนชื่อแล้ว',
gitStatusConflict: 'มีความขัดแย้ง',
changesClean: 'ไม่มีการเปลี่ยนแปลงในเวิร์กสเปซ',
changesNoHistory: 'ยังไม่มีประวัติการ commit',
changesStageFailed: 'stage ไม่สำเร็จ: {message}',
changesOpGone: 'การดำเนินการนี้ไม่อยู่ในบันทึกเซสชันแล้ว',
newTab: 'Tab ใหม่',
brokenSymlink: 'Symlink เสีย',
close: 'ปิด',
@@ -120,7 +125,6 @@ export const th: Record<string, string> = {
error: 'โหลดล้มเหลว',
retry: 'ลองอีกครั้ง',
notRepo: 'ไดเรกทอรีนี้ไม่ใช่ git repository',
noChanges: 'ไม่มีการเปลี่ยนแปลง',
statusTruncated: 'การเปลี่ยนแปลงมากเกินไป แสดงเพียง 2000 รายการแรก',
stage: 'Stage',
unstage: 'Unstage',
+25 -21
View File
@@ -57,26 +57,31 @@ 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ç',
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',
openInApp: 'Birlikte aç',
openInAppDefault: 'Varsayılan uygulamayla aç',
openInAppEmpty: 'Bunu açabilecek bir uygulama yok',
openInAppFailed: 'Açılamadı: {path}',
revealInFileManager: 'Dosya yöneticisinde göster',
filesSelected: '{count} öğe seçildi',
clearSelection: 'Seçimi temizle',
copyPaths: 'Yolları kopyala',
deleteSelected: 'Seçilenleri sil',
deleteSelectedTitle: 'Seçili {count} öğe silinsin mi?',
deleteSelectedDesc: 'Bu dosyalar ve klasörler kalıcı olarak silinecek. Bu işlem geri alınamaz.',
newFolder: 'Yeni klasör',
newFolderPlaceholder: 'Klasör adı',
newFolderInvalid: 'Klasör adı geçersiz',
filesTruncated: 'Bu klasörde çok fazla öğe var; yalnızca ilk kısmı gösteriliyor',
gitStatusModified: 'Değiştirildi',
gitStatusAdded: 'Eklendi',
gitStatusDeleted: 'Silindi',
gitStatusUntracked: 'İzlenmiyor',
gitStatusRenamed: 'Yeniden adlandırıldı',
gitStatusConflict: 'Çakışma',
changesClean: 'Çalışma alanında değişiklik yok',
changesNoHistory: 'Henüz commit geçmişi yok',
changesStageFailed: 'Sahneleme başarısız: {message}',
changesOpGone: 'Bu işlem artık oturum günlüğünde değil',
newTab: 'Yeni sekme',
brokenSymlink: 'Bozuk sembolik bağ',
close: 'Kapat',
@@ -120,7 +125,6 @@ export const tr: Record<string, string> = {
error: 'Yükleme başarısız',
retry: 'Yeniden dene',
notRepo: 'Bu dizin bir git deposu değil',
noChanges: 'Değişiklik yok',
statusTruncated: 'Çok fazla değişiklik var; yalnızca ilk 2000 giriş gösteriliyor',
stage: 'Sahnele',
unstage: 'Sahnelemeden kaldır',
+25 -21
View File
@@ -57,26 +57,31 @@ 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',
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',
openInApp: 'Mở bằng',
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}',
revealInFileManager: 'Hiển thị trong trình quản lý tệp',
filesSelected: 'Đã chọn {count}',
clearSelection: 'Bỏ chọn',
copyPaths: 'Sao chép đường dẫn',
deleteSelected: 'Xóa mục đã chọn',
deleteSelectedTitle: 'Xóa {count} mục đã chọn?',
deleteSelectedDesc: 'Các tệp và thư mục này sẽ bị xóa vĩnh viễn, không thể khôi phục.',
newFolder: 'Thư mục mới',
newFolderPlaceholder: 'Tên thư mục',
newFolderInvalid: 'Tên thư mục không hợp lệ',
filesTruncated: 'Thư mục này có quá nhiều mục; chỉ hiển thị phần đầu',
gitStatusModified: 'Đã sửa',
gitStatusAdded: 'Đã thêm',
gitStatusDeleted: 'Đã xóa',
gitStatusUntracked: 'Chưa theo dõi',
gitStatusRenamed: 'Đã đổi tên',
gitStatusConflict: 'Xung đột',
changesClean: 'Không có thay đổi trong workspace',
changesNoHistory: 'Chưa có lịch sử commit',
changesStageFailed: 'Stage thất bại: {message}',
changesOpGone: 'Thao tác này không còn trong nhật ký phiên',
newTab: 'Tab mới',
brokenSymlink: 'Symlink hỏng',
close: 'Đóng',
@@ -120,7 +125,6 @@ export const vi: Record<string, string> = {
error: 'Tải thất bại',
retry: 'Thử lại',
notRepo: 'Thư mục hiện tại không phải repo git',
noChanges: 'Không có thay đổi',
statusTruncated: 'Quá nhiều thay đổi; chỉ hiển thị 2000 mục đầu tiên',
stage: 'Stage',
unstage: 'Unstage',
+25 -21
View File
@@ -72,26 +72,31 @@ export const zhHK: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
unpinOpenWith: '取消固定',
openWithExplorer: '檔案總管',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 遠端主機',
openWithSettingsSshDesc: '留空為本地工作區;填入 user@host 或 SSH 別名後,VSCode 系開啟方式將改用 vscode-remote/ssh-remote 協議,檔案總管 / Zed / 非 VSCode 系自訂編輯器將從選單隱藏',
openWithSettingsSshPlaceholder: 'user@host 或 SSH 別名',
openWithSettingsCustomTitle: '自訂編輯器',
openWithSettingsCustomDesc: '名稱 + URL 範本({path} 佔位符)+ 是否 VSCode 系;SSH 模式下僅 VSCode 系可開啟遠端',
openWithSettingsAdd: '新增',
openWithSettingsName: '名稱',
openWithSettingsTemplate: '如 cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInApp: '開啟方式',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除所選',
deleteSelectedTitle: '刪除選取的 {count} 項?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在工作階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +140,6 @@ export const zhHK: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
+25 -21
View File
@@ -72,26 +72,31 @@ export const zhMO: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
unpinOpenWith: '取消固定',
openWithExplorer: '檔案總管',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 遠端主機',
openWithSettingsSshDesc: '留空為本地工作區;填入 user@host 或 SSH 別名後,VSCode 系開啟方式將改用 vscode-remote/ssh-remote 協議,檔案總管 / Zed / 非 VSCode 系自訂編輯器將從選單隱藏',
openWithSettingsSshPlaceholder: 'user@host 或 SSH 別名',
openWithSettingsCustomTitle: '自訂編輯器',
openWithSettingsCustomDesc: '名稱 + URL 範本({path} 佔位符)+ 是否 VSCode 系;SSH 模式下僅 VSCode 系可開啟遠端',
openWithSettingsAdd: '新增',
openWithSettingsName: '名稱',
openWithSettingsTemplate: '如 cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInApp: '開啟方式',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用於開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除所選',
deleteSelectedTitle: '刪除選取的 {count} 項?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在工作階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +140,6 @@ export const zhMO: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
+25 -21
View File
@@ -72,26 +72,31 @@ export const zhTW: Record<string, string> = {
editorEmptyHint: '從右側檔案樹或上方路徑輸入框選擇檔案開始預覽',
openFileNewTab: '在新 Tab 中開啟',
openFileSide: '在側邊開啟',
openWithMenu: '在應用程式中開啟',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到選單',
unpinOpenWith: '取消固定',
openWithExplorer: '檔案總管',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 遠端主機',
openWithSettingsSshDesc: '留空為本地工作區;填入 user@host 或 SSH 別名後,VSCode 系開啟方式將改用 vscode-remote/ssh-remote 協議,檔案總管 / Zed / 非 VSCode 系自訂編輯器將從選單隱藏',
openWithSettingsSshPlaceholder: 'user@host 或 SSH 別名',
openWithSettingsCustomTitle: '自訂編輯器',
openWithSettingsCustomDesc: '名稱 + URL 範本({path} 佔位符)+ 是否 VSCode 系;SSH 模式下僅 VSCode 系可開啟遠端',
openWithSettingsAdd: '新增',
openWithSettingsName: '名稱',
openWithSettingsTemplate: '如 cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: '該編輯器使用 VSCode 的 URL 協議(支援 SSH 遠端開啟)',
openWithSettingsRemove: '刪除',
openWithSettingsInvalidHint: '名稱或範本(需含 {path} 且以 scheme:// 開頭)未填寫的編輯器不會出現在選單中',
openInApp: '開啟方式',
openInAppDefault: '用預設應用程式開啟',
openInAppEmpty: '沒有可用來開啟它的應用程式',
openInAppFailed: '無法開啟:{path}',
revealInFileManager: '在檔案總管中顯示',
filesSelected: '已選取 {count} 項',
clearSelection: '取消選取',
copyPaths: '複製路徑',
deleteSelected: '刪除選取項目',
deleteSelectedTitle: '刪除選取的 {count} 個項目?',
deleteSelectedDesc: '這些檔案與資料夾將被永久刪除,無法復原。',
newFolder: '新增資料夾',
newFolderPlaceholder: '資料夾名稱',
newFolderInvalid: '資料夾名稱無效',
filesTruncated: '此目錄項目過多,僅顯示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已刪除',
gitStatusUntracked: '未追蹤',
gitStatusRenamed: '已重新命名',
gitStatusConflict: '有衝突',
changesClean: '工作區沒有變更',
changesNoHistory: '還沒有提交歷史',
changesStageFailed: '暫存失敗:{message}',
changesOpGone: '此操作已不在作業階段記錄中',
newTab: '新增標籤',
brokenSymlink: '失效的符號連結',
close: '關閉',
@@ -135,7 +140,6 @@ export const zhTW: Record<string, string> = {
error: '載入失敗',
retry: '重試',
notRepo: '目前目錄不是 git 倉庫',
noChanges: '沒有變更',
statusTruncated: '變更過多,僅顯示前 2000 條',
stage: '暫存',
unstage: '取消暫存',
+66 -43
View File
@@ -69,26 +69,31 @@ export const zh = {
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
openWithMenu: '在应用中打开',
openWithSshSuffix: ' (SSH)',
pinOpenWith: '固定到菜单',
unpinOpenWith: '取消固定',
openWithExplorer: '资源管理器',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH 远端主机',
openWithSettingsSshDesc: '留空为本地工作区;填入 user@host 或 SSH 别名后,VSCode 系打开方式将改用 vscode-remote/ssh-remote 协议,资源管理器 / Zed / 非 VSCode 系自定义编辑器将从菜单隐藏',
openWithSettingsSshPlaceholder: 'user@host 或 SSH 别名',
openWithSettingsCustomTitle: '自定义编辑器',
openWithSettingsCustomDesc: '名称 + URL 模板({path} 占位符)+ 是否 VSCode 系;SSH 模式下仅 VSCode 系可打开远端',
openWithSettingsAdd: '添加',
openWithSettingsName: '名称',
openWithSettingsTemplate: '如 cursor://file/{path}',
openWithSettingsFamily: 'VSCode 系',
openWithSettingsFamilyDesc: '该编辑器使用 VSCode 的 URL 协议(支持 SSH 远端打开)',
openWithSettingsRemove: '删除',
openWithSettingsInvalidHint: '名称或模板(需含 {path} 且以 scheme:// 开头)未填写的编辑器不会出现在菜单中',
openInApp: '打开方式',
openInAppDefault: '用默认应用打开',
openInAppEmpty: '没有可用于打开它的应用',
openInAppFailed: '无法打开:{path}',
revealInFileManager: '在文件管理器中显示',
filesSelected: '已选择 {count} 项',
clearSelection: '取消选择',
copyPaths: '复制路径',
deleteSelected: '删除所选',
deleteSelectedTitle: '删除选中的 {count} 项?',
deleteSelectedDesc: '这些文件与文件夹将被永久删除,无法恢复。',
newFolder: '新建文件夹',
newFolderPlaceholder: '文件夹名称',
newFolderInvalid: '文件夹名称无效',
filesTruncated: '该目录条目过多,仅显示前一部分',
gitStatusModified: '已修改',
gitStatusAdded: '新增',
gitStatusDeleted: '已删除',
gitStatusUntracked: '未跟踪',
gitStatusRenamed: '已重命名',
gitStatusConflict: '有冲突',
changesClean: '工作区没有变更',
changesNoHistory: '还没有提交历史',
changesStageFailed: '暂存失败:{message}',
changesOpGone: '该操作已不在会话记录中',
newTab: '新建标签页',
brokenSymlink: '失效的软链接',
close: '关闭',
@@ -131,7 +136,6 @@ export const zh = {
error: '加载失败',
retry: '重试',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
statusTruncated: '变更过多,仅显示前 2000 条',
stage: '暂存',
unstage: '取消暂存',
@@ -554,26 +558,31 @@ export const en: Record<keyof typeof zh, string> = {
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
openWithMenu: 'Open with',
openWithSshSuffix: ' (SSH)',
pinOpenWith: 'Pin to menu',
unpinOpenWith: 'Unpin',
openWithExplorer: 'File Manager',
openWithVscode: 'VS Code',
openWithCursor: 'Cursor',
openWithZed: 'Zed',
openWithSettingsSshTitle: 'SSH remote host',
openWithSettingsSshDesc: 'Empty = local workspace; with a user@host or SSH alias, VSCode-family openers switch to the vscode-remote/ssh-remote protocol and the File Manager / Zed / non-VSCode-family custom editors are hidden from the menu',
openWithSettingsSshPlaceholder: 'user@host or SSH alias',
openWithSettingsCustomTitle: 'Custom editors',
openWithSettingsCustomDesc: 'Name + URL template ({path} placeholder) + VSCode-family flag; in remote mode only VSCode-family editors can open a remote path',
openWithSettingsAdd: 'Add',
openWithSettingsName: 'Name',
openWithSettingsTemplate: 'e.g. cursor://file/{path}',
openWithSettingsFamily: 'VSCode-family',
openWithSettingsFamilyDesc: 'This editor speaks the VSCode URL dialect (supports SSH-remote opens)',
openWithSettingsRemove: 'Remove',
openWithSettingsInvalidHint: 'Editors with a missing name or a template without {path} / scheme:// are not shown in the menu',
openInApp: 'Open with',
openInAppDefault: 'Open with default app',
openInAppEmpty: 'No application can open it',
openInAppFailed: 'Could not open: {path}',
revealInFileManager: 'Reveal in File Manager',
filesSelected: '{count} selected',
clearSelection: 'Clear selection',
copyPaths: 'Copy paths',
deleteSelected: 'Delete selected',
deleteSelectedTitle: 'Delete {count} selected items?',
deleteSelectedDesc: 'These files and folders will be permanently deleted. This cannot be undone.',
newFolder: 'New folder',
newFolderPlaceholder: 'Folder name',
newFolderInvalid: 'Invalid folder name',
filesTruncated: 'This folder has too many entries; only the first ones are shown',
gitStatusModified: 'Modified',
gitStatusAdded: 'Added',
gitStatusDeleted: 'Deleted',
gitStatusUntracked: 'Untracked',
gitStatusRenamed: 'Renamed',
gitStatusConflict: 'Conflicted',
changesClean: 'No changes in the working tree',
changesNoHistory: 'No commits yet',
changesStageFailed: 'Staging failed: {message}',
changesOpGone: 'This operation is no longer in the session log',
newTab: 'New tab',
brokenSymlink: 'Broken symlink',
close: 'Close',
@@ -616,7 +625,6 @@ export const en: Record<keyof typeof zh, string> = {
error: 'Failed to load',
retry: 'Retry',
notRepo: 'This directory is not a git repository',
noChanges: 'No changes',
statusTruncated: 'Too many changes; showing the first 2,000 entries',
stage: 'Stage',
unstage: 'Unstage',
@@ -1069,6 +1077,21 @@ export function attachLocale(service: {
* @returns the translated text, or undefined when unavailable.
*/
export function chatT(key: string, params?: Record<string, string | number>): string | undefined {
return hostT('chat', key, params)
}
/**
* Translate a key in one of the HOST's own locale namespaces. Same contract
* and same deliberately-untyped seam as {@link chatT} — the plugin reads host
* wording it would otherwise have to duplicate across 20 dictionaries (the
* chat activity line, the open-in-app application names).
*
* @param ns - host namespace (e.g. `'open-in-app'`).
* @param key - full namespace key (e.g. `'app.vscode'`).
* @param params - `{name}` placeholders to interpolate (the host does this).
* @returns the translated text, or undefined when unavailable.
*/
export function hostT(ns: string, key: string, params?: Record<string, string | number>): string | undefined {
const service = localeService
if (service === undefined) return undefined
try {
@@ -1076,7 +1099,7 @@ export function chatT(key: string, params?: Record<string, string | number>): st
// reference (`const bind = service.bind; bind('chat')`) throws
// "Cannot read properties of undefined (reading 'bound')" — caught by the
// real-host mount lane, invisible to closure fakes in unit tests.
const translate = service.bind?.('chat')
const translate = service.bind?.(ns)
if (typeof translate !== 'function') return undefined
const text = translate(key, params)
return typeof text === 'string' && text !== '' && text !== key ? text : undefined
-68
View File
@@ -1,68 +0,0 @@
/**
* 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])
}
+214
View File
@@ -0,0 +1,214 @@
/**
* The client half of "open with an application" — a thin adapter over DSH's
* OWN open-in-app capability, replacing the plugin's former home-grown
* vocabulary (URL templates, SSH hosts, custom editors, a spawn route).
*
* Two host facilities back this, and the adapter simply picks the right one:
*
* - The Host Remote (`ctx.remote.session`): `canOpenWorkspacePath()` reports
* whether this deployment can hand a path to a native desktop,
* `workspacePathApplications({path})` lists the OS handlers REGISTERED for
* one file, and `openWorkspacePath({path, action?, application?})` opens a
* path or reveals it in the file manager. The host validates the path and
* the application id before spawning anything.
* - The host's webServer routes: `GET /open-in-app/apps` is the probed
* application CATALOG (menu order) and `POST /open-in-app/open` launches
* one catalog app on a DIRECTORY — the "open this workspace in VS Code"
* gesture. Catalog ids are not file-handler ids, so `open()` tries the
* Remote first and falls back to the catalog route.
*
* Everything degrades to "no control" when the host says the desktop is
* unavailable: the caller hides the section instead of offering dead rows.
*/
import { hostT } from './locales.ts'
/** The host route paths (see `@deepseek-ai/dsh-host-open-in-app/shared`). */
const APPS_PATH = '/open-in-app/apps'
const ICON_PATH = '/open-in-app/icon'
const OPEN_PATH = '/open-in-app/open'
/** One application that can open the path. */
export interface OpenInAppEntry {
/** Host-side id: an OS handler id for files, a catalog id for directories. */
id: string
/** Display name (the OS reports handlers; the catalog id is localized). */
name: string
/** PNG/SVG data URL for a handler, an icon route for a catalog app, else null. */
icon: string | null
/** Whether the OS reports it as the default handler (files only). */
isDefault: boolean
}
/** The adapter the pages consume. */
export interface OpenInApp {
/** Whether the host can open paths on a desktop; null until probed. */
available(): boolean | null
/** Probe availability once (shared across callers). */
probe(): Promise<boolean>
/** The installed-application catalog, in host menu order (directories). */
directoryApps(): Promise<readonly OpenInAppEntry[]>
/** The OS handlers registered for one file, or null when the query failed. */
fileApps(path: string): Promise<readonly OpenInAppEntry[] | null>
/** Open a path with its default handler, or with one explicit application. */
open(path: string, application?: string): Promise<boolean>
/** Reveal a path in the OS file manager. */
reveal(path: string): Promise<boolean>
}
/** The slice of `ctx.remote.session` this module calls (structurally typed). */
interface SessionRemote {
canOpenWorkspacePath(): Promise<{ ok: boolean; value?: boolean }>
workspacePathApplications(request: { path: string }): Promise<{ ok: boolean; value?: readonly RawFileApplication[] }>
openWorkspacePath(request: { path: string; action?: 'reveal'; application?: string }): Promise<{ ok: boolean }>
}
/** The host's `NativeFileApplication` shape. */
interface RawFileApplication {
id: string
name: string
default: boolean
icon: string | null
}
/** A ctx that can resolve client services (the cordis client root). */
interface CtxLike {
get(name: string): unknown
}
/** The host's `open-in-app` locale namespace, read through the plugin's bridge. */
function appLabel(id: string): string {
const text = hostT('open-in-app', `app.${id}`)
if (text !== undefined) return text
// The namespace may be absent (host older than the capability): fall back to
// a readable form of the id itself.
return id.charAt(0).toUpperCase() + id.slice(1)
}
/**
* The remote namespace, or undefined when the client assembly lacks it.
*
* Cordis throws when a service (or a nested property of one) is read without
* a matching `inject` entry — the client bundle declares `remote` and
* `remote.session` for exactly this call, but a composition that mounts the
* sidebar without the Host Remote assembly must degrade to "no control"
* instead of taking a render down with it, so the read is guarded.
*/
function remoteOf(ctx: CtxLike): SessionRemote | undefined {
try {
const remote = ctx.get('remote') as { session?: SessionRemote } | undefined | null
const session = remote?.session
return session !== undefined && typeof session.openWorkspacePath === 'function' ? session : undefined
} catch {
return undefined
}
}
/** Build the adapter for one client ctx. */
export function createOpenInApp(ctx: CtxLike): OpenInApp {
const remote = remoteOf(ctx)
/** Tri-state availability; one shared probe per page. */
let availability: boolean | null = null
let probing: Promise<boolean> | null = null
const probe = async (): Promise<boolean> => {
if (availability !== null) return availability
if (remote === undefined) {
availability = false
return false
}
probing ??= remote.canOpenWorkspacePath()
.then(result => {
availability = result.ok && result.value === true
return availability
})
.catch(() => {
availability = false
return false
})
.finally(() => { probing = null })
return probing
}
const directoryApps = async (): Promise<readonly OpenInAppEntry[]> => {
if (!await probe()) return []
try {
const response = await fetch(APPS_PATH)
if (!response.ok) return []
const payload = await response.json() as { apps?: unknown }
const ids = Array.isArray(payload.apps) ? payload.apps.filter((id): id is string => typeof id === 'string') : []
return ids.map(id => ({
id,
name: appLabel(id),
icon: `${ICON_PATH}/${encodeURIComponent(id)}`,
isDefault: false,
}))
} catch {
return []
}
}
const fileApps = async (path: string): Promise<readonly OpenInAppEntry[] | null> => {
if (remote === undefined || !await probe()) return null
try {
const result = await remote.workspacePathApplications({ path })
if (!result.ok || result.value === undefined) return null
return result.value.map(app => ({
id: app.id,
name: app.name,
icon: app.icon,
isDefault: app.default,
}))
} catch {
return null
}
}
const openViaRemote = async (path: string, application: string | undefined): Promise<boolean> => {
if (remote === undefined) return false
try {
const result = await remote.openWorkspacePath({
path,
...(application !== undefined ? { application } : {}),
})
return result.ok
} catch {
return false
}
}
const open = async (path: string, application?: string): Promise<boolean> => {
if (await openViaRemote(path, application)) return true
// A catalog id is not a file-handler id: the host's directory route is the
// one that launches "VS Code on this folder".
if (application === undefined) return false
try {
const response = await fetch(OPEN_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: application, path }),
})
return response.ok
} catch {
return false
}
}
const reveal = async (path: string): Promise<boolean> => {
if (remote === undefined) return false
try {
const result = await remote.openWorkspacePath({ path, action: 'reveal' })
return result.ok
} catch {
return false
}
}
return {
available: () => availability,
probe,
directoryApps,
fileApps,
open,
reveal,
}
}
-135
View File
@@ -1,135 +0,0 @@
/**
* The editor tab's custom settings panel ("打开方式"): the file tree's
* "open with" configuration — the optional SSH host marking the workspace as
* remote, and the user-defined editors (name + URL template with `{path}` +
* whether they speak the VSCode URL dialect). Persisted as the editor
* blob's `openWith` key through the settings popup's `updatePluginSetting`.
*
* The popup renders the declarative rows (the editorExplorer picker) ABOVE
* this panel — SettingsBody renders the custom panel after the row list, so
* this component owns only its own section.
*/
import { useState } from 'react'
import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import {
isValidCustomEditor,
newCustomEditorId,
parseOpenWithConfig,
type CustomEditor,
type OpenWithConfig,
} from './open-with.ts'
import { t } from './locales.ts'
import css from './SideCardSection.module.css'
export function OpenWithSettings(props: {
pluginSettings: Record<string, unknown>
updatePluginSetting: (key: string, value: unknown) => void
}) {
const { pluginSettings, updatePluginSetting } = props
// Local draft seeded from the persisted blob (each commit re-renders the
// popup with the round-tripped prefs; the local state stays authoritative
// while the popup is open, so typing never fights an in-flight write).
const [draft, setDraft] = useState<OpenWithConfig>(() => parseOpenWithConfig(pluginSettings.openWith))
const commit = (next: OpenWithConfig): void => {
setDraft(next)
updatePluginSetting('openWith', next)
}
const setSshHost = (sshHost: string): void => commit({ ...draft, sshHost })
const patchCustom = (id: string, patch: Partial<CustomEditor>): void => {
commit({
...draft,
customEditors: draft.customEditors.map(editor => editor.id === id ? { ...editor, ...patch } : editor),
})
}
const removeCustom = (id: string): void => {
commit({
...draft,
customEditors: draft.customEditors.filter(editor => editor.id !== id),
// A removed editor can never stay pinned (the menu prunes unknown ids
// anyway; this keeps the blob clean).
pinned: draft.pinned.filter(pinnedId => pinnedId !== `custom:${id}`),
})
}
const addCustom = (): void => {
commit({
...draft,
customEditors: [...draft.customEditors, { id: newCustomEditorId(), name: '', urlTemplate: '', isVscodeFamily: false }],
})
}
const hasInvalid = draft.customEditors.some(editor => !isValidCustomEditor(editor))
return (
<div className={css.popupRows}>
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{t('openWithSettingsSshTitle')}</span>
<span className={css.desc}>{t('openWithSettingsSshDesc')}</span>
</span>
<input
className={css.typedInput}
value={draft.sshHost}
placeholder={t('openWithSettingsSshPlaceholder')}
spellCheck={false}
onChange={(event) => { setSshHost(event.target.value) }}
/>
</div>
<div className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{t('openWithSettingsCustomTitle')}</span>
<span className={css.desc}>{t('openWithSettingsCustomDesc')}</span>
</span>
<span className={css.control}>
<button type="button" className={css.done} onClick={addCustom}>
{t('openWithSettingsAdd')}
</button>
</span>
</div>
{draft.customEditors.map(editor => (
<div key={editor.id} className={css.openWithEditorRow}>
<input
className={css.openWithEditorInput}
value={editor.name}
placeholder={t('openWithSettingsName')}
spellCheck={false}
onChange={(event) => { patchCustom(editor.id, { name: event.target.value }) }}
/>
<input
className={css.openWithEditorTemplate}
value={editor.urlTemplate}
placeholder={t('openWithSettingsTemplate')}
spellCheck={false}
onChange={(event) => { patchCustom(editor.id, { urlTemplate: event.target.value }) }}
/>
<label className={css.openWithFamily} title={t('openWithSettingsFamilyDesc')}>
<input
type="checkbox"
checked={editor.isVscodeFamily}
onChange={(event) => { patchCustom(editor.id, { isVscodeFamily: event.currentTarget.checked }) }}
/>
<span>{t('openWithSettingsFamily')}</span>
</label>
<button
type="button"
className={css.openWithRemove}
aria-label={t('openWithSettingsRemove')}
title={t('openWithSettingsRemove')}
onClick={() => { removeCustom(editor.id) }}
>
<IconCloseOutlineRegular size={14} />
</button>
</div>
))}
{hasInvalid && (
<div className={css.openWithHint} role="note">
{t('openWithSettingsInvalidHint')}
</div>
)}
</div>
)
}
-221
View File
@@ -1,221 +0,0 @@
/**
* The file tree's "open with" vocabulary: which external editors / file
* managers the context menu can hand a row's path to, how the per-user
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
* persisted blob, and how the external URL is built (local `file` URLs and
* VSCode-family SSH-remote URLs).
*
* Pure by design (no React / no `api`), so the whole surface is unit-testable
* without a DOM or a host route. The menu and the settings panel only consume
* the exported types and functions.
*/
import type { CopyKey } from './locales.ts'
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
export interface CustomEditor {
id: string
/** Human label shown in the menu (e.g. "Windsurf"). */
name: string
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
urlTemplate: string
/** Whether the editor speaks the VSCode URL dialect — the only editors
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
isVscodeFamily: boolean
}
/** The per-user "open with" configuration (one key of the editor blob). */
export interface OpenWithConfig {
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
sshHost: string
/** User-defined editors, appended after the built-ins. */
customEditors: CustomEditor[]
/** Ids of open-with targets pinned to the menu's top level
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
pinned: string[]
}
/** One menu-visible open target (built-in or custom, SSH-filtered). */
export interface OpenWithTarget {
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
id: string
/** Locale key of a built-in label (custom editors carry `name` instead). */
nameKey?: CopyKey
/** User-defined label (custom editors only; '' for built-ins). */
name: string
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
kind: 'reveal' | 'url'
/** URL template with `{path}`; undefined for reveal targets. */
urlTemplate?: string
/** Whether the editor talks the VSCode URL dialect. */
isVscodeFamily: boolean
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
localOnly: boolean
}
/** The default open-with configuration (fresh documents). */
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
sshHost: '',
customEditors: [],
pinned: [],
}
/** The built-in open targets, in menu order. */
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
{
id: 'explorer',
nameKey: 'openWithExplorer',
name: '',
kind: 'reveal',
isVscodeFamily: false,
localOnly: true,
},
{
id: 'vscode',
nameKey: 'openWithVscode',
name: '',
kind: 'url',
urlTemplate: 'vscode://file/{path}',
isVscodeFamily: true,
localOnly: false,
},
{
id: 'cursor',
nameKey: 'openWithCursor',
name: '',
kind: 'url',
urlTemplate: 'cursor://file/{path}',
isVscodeFamily: true,
localOnly: false,
},
{
id: 'zed',
nameKey: 'openWithZed',
name: '',
kind: 'url',
urlTemplate: 'zed://file/{path}',
isVscodeFamily: false,
localOnly: true,
},
]
/** Whether a persisted value makes a structurally valid custom-editor row.
* Name/template may be empty — the settings panel edits rows in place and
* an in-progress row must survive the round-trip; the MENU hides rows that
* fail the stricter {@link isValidCustomEditor} check. */
function isCustomEditor(value: unknown): value is CustomEditor {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
return typeof record.id === 'string' && record.id !== ''
&& typeof record.name === 'string'
&& typeof record.urlTemplate === 'string'
&& typeof record.isVscodeFamily === 'boolean'
}
/**
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
* are kept verbatim (unknown ids are pruned when the targets are resolved —
* the menu is the only consumer of the resolved list).
*/
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
const record = raw as Record<string, unknown>
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
const customEditors = Array.isArray(record.customEditors)
? record.customEditors.filter(isCustomEditor)
: []
const pinned = Array.isArray(record.pinned)
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
: []
return { sshHost, customEditors, pinned }
}
/** Whether a custom editor id belongs to this config (id prefix match). */
function customIdOf(id: string): string {
return `custom:${id}`
}
/**
* The menu-visible open targets, in order (built-ins then custom editors).
* In SSH mode the local-only targets (the OS file manager, Zed, custom
* editors without the VSCode dialect) are dropped — they cannot reach a
* remote path. Unknown pinned ids are pruned here too.
*/
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
const ssh = config.sshHost.trim() !== ''
const targets: OpenWithTarget[] = [
...OPEN_WITH_BUILTINS,
...config.customEditors
.filter(isValidCustomEditor)
.map((editor): OpenWithTarget => ({
id: customIdOf(editor.id),
name: editor.name,
kind: 'url',
urlTemplate: editor.urlTemplate,
isVscodeFamily: editor.isVscodeFamily,
localOnly: !editor.isVscodeFamily,
})),
]
return targets.filter(target => !(ssh && target.localOnly))
}
/** The SSH hint appended to a target's label in remote mode. */
export function openWithSshActive(config: OpenWithConfig): boolean {
return config.sshHost.trim() !== ''
}
/**
* The URL to open for one resolved target, or undefined when the target has
* no URL form (reveal) or the template is malformed. The path is inserted
* RAW into the template (browsers percent-encode as needed; VSCode-family
* URL parsers consume the absolute path with its leading slash, e.g.
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
*/
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
const normalized = normalizeUrlPath(path)
const ssh = openWithSshActive(config)
if (ssh && target.isVscodeFamily) {
const scheme = schemeOf(target.urlTemplate)
if (scheme === undefined) return undefined
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
}
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
return target.urlTemplate.replace('{path}', normalized)
}
/** Whether a template starts with a `scheme://` prefix (the only shape the
* host's external opener accepts and the settings panel suggests). */
function hasUrlScheme(template: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
}
/** The scheme of a URL template (the part before the first ':'), or undefined. */
function schemeOf(template: string): string | undefined {
const at = template.indexOf(':')
if (at <= 0) return undefined
const scheme = template.slice(0, at)
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
}
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
export function normalizeUrlPath(path: string): string {
return path.replace(/\\/g, '/')
}
/** A fresh custom-editor id (uuid when available, time-based fallback). */
export function newCustomEditorId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Validate one custom-editor row before the settings panel accepts it. */
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
return row.name.trim() !== ''
&& row.urlTemplate.includes('{path}')
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
}
-43
View File
@@ -1,43 +0,0 @@
/**
* 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)
})
}
+141 -129
View File
@@ -664,11 +664,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding: 4px 8px 8px;
}
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
fill, 22px indent step, 14px ink) with a mount fade so expanding a
/* One tree row: the plugin's native list rhythm (28px, radius 4, inset,
hover fill, 22px indent step, 12px ink) with a mount fade so expanding a
directory reveals its children gently (stable keys keep existing rows from
replaying it). */
.explorerRow {
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -678,16 +679,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: 34px;
height: 28px;
padding: 0 8px;
border: none;
background: transparent;
font: var(--dsw-font-s-14);
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
white-space: nowrap;
border-radius: 8px;
border-radius: 4px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
@@ -695,6 +696,41 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
background: var(--dsw-alias-interactive-bg-hover);
}
/* A multi-selected row: the native selected fill plus the design's 2px
inset accent bar (a pseudo element, so metrics never shift between rows).
Distinct from the reveal tint below — a reveal is "here it is", a
selection is "this is the batch's member". */
.explorerRowSelected {
background: var(--dsw-alias-interactive-bg-active);
}
.explorerRowSelected::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
}
/* Git-changed file names (VS Code's ink): the row keeps one class and the
tone arrives as a data attribute, so the mapping lives in one place. */
.explorerGitName[data-git-tone='modified'] { color: var(--dsw-alias-state-warn-primary); }
.explorerGitName[data-git-tone='added'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='untracked'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='deleted'] { color: var(--dsw-alias-state-error-primary); }
.explorerGitName[data-git-tone='renamed'] { color: var(--dsw-alias-state-business-primary); }
.explorerGitName[data-git-tone='copied'] { color: var(--dsw-alias-state-success-primary); }
.explorerGitName[data-git-tone='conflict'] { color: var(--dsw-alias-state-error-primary); }
/* A directory with a change anywhere below it: tinted, but never lettered
(the letter belongs to the file that actually changed). */
.explorerDirChanged {
color: var(--dsw-alias-state-business-primary);
}
/* A "Show in folder" reveal highlight: the target row stays tinted until
the transient reveal set is cleared (a reload starts unhighlighted).
The DSH business-blue surface (the same source the host's nav items use
@@ -720,7 +756,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
.explorerDir {
font: var(--dsw-font-s-strong-14);
font: var(--dsw-font-xxs-strong-12);
}
.explorerHidden {
@@ -748,9 +784,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
white-space: nowrap;
}
/* ── Inline rename editor ─────────────────────────────────────────────────
The row swaps its name span for a plain input (same row metrics: 34px
height, 6px gap, xxs type); only tokens, no hardcoded colors. */
/* ── Inline rename / new-folder editor ───────────────────────────────────
The level's top (or a row) swaps in a plain input (same row metrics: 28px
height, 6px gap, 12px type); only tokens, no hardcoded colors. */
.explorerRenaming {
cursor: text;
}
@@ -759,10 +795,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
height: 24px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-state-business-primary);
border-radius: 6px;
border-radius: 3px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
@@ -796,33 +832,34 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
.explorerActionErrorClose {
flex: none;
/* The multi-selection bar: the kit's 28px section band, sat above the root
row (it never covers a row, so the tree stays fully aimable). Only the
fill/rhythm differ from the kit band — geometry stays the kit's. */
.explorerSelectionBar {
margin-bottom: 4px;
border-radius: 4px;
background: var(--dsw-alias-bg-layer-2);
}
.explorerSelectionActions {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
gap: 4px;
}
.explorerActionErrorClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
/* The leading glyph of one "open in app" menu entry (the host's icon route
or a data URL). Fixed box: the menu's own icon slot sizing. */
.explorerAppIcon {
display: block;
width: 14px;
height: 14px;
object-fit: contain;
}
/* The delete-confirmation modal's description paragraph (the git lens's
.gitConfirmDesc sibling — same quiet single-question look). */
.explorerConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
/* The tree parked while the search results panel owns the surface (the tree
must stay MOUNTED: its level cache is the whole point). */
.explorerHiddenPane {
display: none;
}
/* The hover-revealed @-reference button at the row's far right (and the
@@ -884,65 +921,92 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
text-align: center;
}
/* Drag a file/folder over a tree row: the target directory row gets a filled
highlight (drops land inside it; file rows target their parent directory).
The zone frame around the whole tree is the portaled .uploadDropZone. */
/* Drag a file/folder over a tree row: the target directory row takes the
accent-tinted fill and a dashed business edge (drops land inside it; file
rows target their parent directory). The zone frame around the whole tree
is the portaled .uploadDropZone. */
.explorerRowDropTarget {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
background: var(--dsw-alias-interactive-bg-hover-accent);
outline: 1px dashed var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
/* The drop mask: one pointer-inert scrim over the whole viewport, portaled
to document.body BELOW the zone frame — the panel-host float layer is
z-1000 (DSH's own page mask sits there too), so 1001/1002 keep this
transient surface on top. The mask is deliberately light: the conversation
column beneath it still takes drops into the chat natively, and the user
never loses sight of the tree. */
.uploadDropMask {
position: fixed;
inset: 0;
z-index: 1001;
pointer-events: none;
background: color-mix(in srgb, var(--dsw-alias-bg-base) 62%, transparent);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The sidebar's drop surface: a pointer-inert frame portaled to
document.body, fixed over the explorer body's viewport rect at z-1001 —
above DSH's own whole-page drop mask (z-1000, see InputBar's
document-level intake). The giant box-shadow spread IS the full-viewport
mask (same token DSH uses for its drop mask): everything dims except the
zone rect, teaching the zone split — the dimmed conversation column still
takes drops into the chat natively, the clear frame marks the tree as the
workspace-upload zone. Deliberate exception to the "panel stays below the
DSH float stack" rule: transient and pointer-events none, so the drop
always lands on the row beneath. The hint pill docks at the TOP edge of
the zone (right under the search row, the first thing the eye meets),
leaving the rows — the actual drop targets — aimable. */
document.body, fixed over the explorer body's viewport rect. The hint pill
docks at the TOP edge of the zone (right under the search row, the first
thing the eye meets), leaving the rows — the actual drop targets —
aimable. */
.uploadDropZone {
position: fixed;
z-index: 1001;
z-index: 1002;
pointer-events: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 10px;
box-shadow: 0 0 0 200vmax var(--dsw-alias-bg-mask-drop);
border: 2px dashed var(--dsw-alias-border-l2);
border-radius: 8px;
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 4%, transparent);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The zone's drop hero, docked at the TOP edge (right under the search
row, the first thing the eye meets): the colorful tray illustration over
the hint pill — stacked so the rows below stay aimable. */
row, the first thing the eye meets): the tray illustration over the hint
pill — stacked so the rows below stay aimable. */
.uploadDropHero {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: 8px;
max-width: 100%;
padding-top: 8px;
}
/* The drop art's two inks (the SVG strokes/fills take them via
currentColor): the accent for the rising arrow and the front card, a quiet
tint for the tray body and the back card, and the surface ink for the
motifs cut out of the cards. */
.uploadDropArtPrimary {
color: var(--dsw-alias-state-business-primary);
}
.uploadDropArtAccent {
color: var(--dsw-alias-state-business-tertiary);
}
.uploadDropArtCut {
color: var(--dsw-alias-bg-layer-1);
}
.uploadDropZonePill {
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: 28px;
max-width: 100%;
padding: 6px 12px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
background: var(--dsw-alias-bg-layer-1);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
font: var(--dsw-font-xxxs-11);
}
.uploadDropZoneText {
@@ -959,7 +1023,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
top: 0;
left: 0;
bottom: 0;
z-index: 1002;
z-index: 1003;
pointer-events: none;
display: flex;
align-items: center;
@@ -968,19 +1032,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The invitation's content: the colorful photo-cards illustration over the
label — the drop overlay is this flow's one brand moment, so it gets the
color the rest of the UI never does (palette borrowed from DSH's native
drop illustration so the two zones read as one family). */
/* The invitation's content: the photo-cards illustration over the label. */
.uploadDropChatCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: 10px;
max-width: 100%;
text-align: center;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-strong-14);
font: var(--dsw-font-xxs-strong-12);
}
/* Full-window upload progress: blurred scrim + card (same mask/backdrop
@@ -1001,12 +1062,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayCard {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
gap: 10px;
min-width: 240px;
max-width: min(420px, calc(100% - 48px));
padding: 20px 24px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 24px;
padding: 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
@@ -1014,8 +1075,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayTitle {
display: flex;
align-items: center;
gap: 8px;
font: var(--dsw-font-s-strong-14);
gap: 6px;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
@@ -1046,7 +1107,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayStatus {
min-height: 1em;
font: var(--dsw-font-xxs-12);
font: var(--dsw-font-xxxs-11);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
@@ -1057,9 +1118,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.uploadOverlayCancel {
align-self: flex-end;
height: 28px;
padding: 0 14px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
@@ -1222,13 +1283,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.editorSearchResult {
display: block;
box-sizing: border-box;
width: 100%;
padding: 4px 8px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 6px;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
line-height: 28px;
text-align: left;
cursor: pointer;
overflow: hidden;
@@ -2044,58 +2108,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
}
/* The file tree "open with" menu rows: the label area is a BLOCK-level flex
row that fills the primitives `.itemLabel` span (which is itself `flex: 1`
inside the row), so the trailing control — the pushpin on submenu rows,
the chevron on the submenu parent — docks at the row's far right, exactly
like the trailing check on selected rows of every other menu. */
.openWithLabel {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.openWithName {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The trailing "expands into a submenu" chevron on the open-with parent row
(the primitives Menu renders no arrow for submenu parents). */
.openWithChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* The pushpin toggle on an "open with" submenu row: brand ink once the
target is pinned to the menu's top level. 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
+300
View File
@@ -0,0 +1,300 @@
/**
* The plugin's ONE git-status store.
*
* Before this module the git status was fetched by the changes tab alone
* (a 2 s poll inside `changes/GitLens.tsx`) while the file tree had no git
* data at all — so a VS Code-style "color the changed rows" feature would
* have added a SECOND poller of the same `git status --porcelain` call. The
* store keeps one snapshot per `sessionId + cwd + worktree`, one poller per
* key while at least one VISIBLE consumer is subscribed, and one path index
* built lazily per snapshot, so the tree (coloring) and the changes page
* (list) read the same answer.
*
* The path index is keyed case-insensitively with '/' separators: git reports
* repo-root-relative POSIX paths while the tree rows carry host paths (a
* Windows host mixes separators), and a color lookup must not depend on that
* difference. The cost is that two paths differing only in case share an
* entry — invisible in the tree, which lists one of them anyway.
*
* Coalescing: a `refresh()` that lands while a fetch is in flight is queued
* once and re-run when it settles — never dropped (the old `GitLens.refresh`
* returned early, so a click during the poll silently did nothing).
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import { api, type GitStatusResult } from '../api.ts'
/** The semantic class of one changed path (drives the row's ink). */
export type GitTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed' | 'copied' | 'conflict'
/** One path's git state, derived from its two-letter porcelain code. */
export interface GitFileStatus {
/** Display letter: the index side wins, else the worktree side ('??' → 'U'). */
letter: string
tone: GitTone
/** The index (staged) side carries a change. */
staged: boolean
/** The worktree (unstaged) side carries a change. */
unstaged: boolean
}
/** The porcelain letter → tone rule (both sides share one vocabulary). */
function toneOfLetter(letter: string): GitTone | undefined {
switch (letter) {
case 'M': return 'modified'
case 'T': return 'modified'
case 'A': return 'added'
case 'D': return 'deleted'
case 'R': return 'renamed'
case 'C': return 'copied'
case 'U': return 'conflict'
default: return undefined
}
}
/**
* Derive one path's status from its porcelain `XY` code, or `undefined` for a
* clean (`' '`) or ignored (`'!!'`) entry. Untracked (`??`) reports 'U' —
* git's own letter for "not in the index" would be a blank.
*/
export function statusOfXY(xy: string): GitFileStatus | undefined {
if (xy === '??') return { letter: 'U', tone: 'untracked', staged: false, unstaged: true }
if (xy.length < 2 || xy === '!!') return undefined
const x = xy[0]!
const y = xy[1]!
if (x === ' ' && y === ' ') return undefined
const staged = x !== ' ' && x !== '?'
const unstaged = y !== ' ' && y !== '?'
// A conflict (any unmerged combination) outranks a plain modification: the
// user must act on it first, so it owns the row's ink.
const conflict = x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
const tone = conflict
? 'conflict'
: toneOfLetter(x !== ' ' && x !== '?' ? x : y) ?? 'modified'
return {
letter: x !== ' ' && x !== '?' ? x : y,
tone,
staged,
unstaged,
}
}
/** The store's normalized path key (case-insensitive, '/' separators). */
function normPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
}
/** Join a repo-root-relative git path onto the repo root. */
function joinRoot(root: string, rel: string): string {
const base = root.replace(/[\\/]+$/, '')
return `${base}/${rel.replace(/^[\\/]+/, '')}`
}
interface Index {
/** Normalized absolute path → status (files only). */
files: Map<string, GitFileStatus>
/** Normalized absolute directories that contain at least one changed file. */
dirs: Set<string>
}
/** Build the lookup index for one snapshot (once per snapshot, O(rows × depth)). */
function buildIndex(snapshot: GitStatusResult): Index {
const files = new Map<string, GitFileStatus>()
const dirs = new Set<string>()
const root = snapshot.root
if (!snapshot.isRepo || root === undefined) return { files, dirs }
for (const entry of snapshot.entries) {
const status = statusOfXY(entry.xy)
if (status === undefined) continue
const absolute = normPath(joinRoot(root, entry.path))
files.set(absolute, status)
let at = absolute.lastIndexOf('/')
while (at > 0) {
const dir = absolute.slice(0, at)
if (dirs.has(dir)) break
dirs.add(dir)
at = dir.lastIndexOf('/')
}
}
return { files, dirs }
}
interface Slot {
key: string
sessionId: string
cwd: string | undefined
worktree: string | undefined
snapshot: GitStatusResult | null
loading: boolean
error: boolean
version: number
/** Bumped per fetch so a late response from an older fetch is dropped. */
generation: number
running: boolean
pending: boolean
visible: number
pollMs: number
timer: number | undefined
listeners: Set<() => void>
index: Index | null
/** The snapshot the current index was built from (identity compare). */
indexSource: GitStatusResult | null
}
const slots = new Map<string, Slot>()
function slotOf(sessionId: string, cwd: string | undefined, worktree: string | undefined): Slot {
const key = `${sessionId}\u0000${cwd ?? ''}\u0000${worktree ?? ''}`
const existing = slots.get(key)
if (existing !== undefined) return existing
const slot: Slot = {
key, sessionId, cwd, worktree,
snapshot: null, loading: false, error: false, version: 0, generation: 0,
running: false, pending: false, visible: 0, pollMs: 2_500, timer: undefined,
listeners: new Set(),
index: null, indexSource: null,
}
slots.set(key, slot)
return slot
}
function notify(slot: Slot): void {
slot.version += 1
for (const listener of slot.listeners) listener()
}
/** Fetch one snapshot; a concurrent call just marks the slot dirty. */
function fetchSlot(slot: Slot): void {
if (slot.running) {
slot.pending = true
return
}
slot.running = true
const generation = ++slot.generation
slot.loading = slot.snapshot === null
notify(slot)
const scope = { sessionId: slot.sessionId, ...(slot.cwd !== undefined ? { cwd: slot.cwd } : {}) }
api.gitStatus(scope, slot.worktree)
.then((snapshot) => {
if (generation !== slot.generation) return
slot.snapshot = snapshot
slot.error = false
})
.catch(() => {
if (generation !== slot.generation) return
slot.error = true
})
.finally(() => {
if (generation !== slot.generation) return
slot.running = false
slot.loading = false
notify(slot)
if (slot.pending) {
slot.pending = false
fetchSlot(slot)
}
})
}
function startTimer(slot: Slot): void {
if (slot.timer !== undefined) return
slot.timer = window.setInterval(() => { fetchSlot(slot) }, slot.pollMs)
}
function stopTimer(slot: Slot): void {
if (slot.timer === undefined) return
window.clearInterval(slot.timer)
slot.timer = undefined
}
function retain(slot: Slot): void {
slot.visible += 1
if (slot.visible === 1) {
if (slot.snapshot === null) fetchSlot(slot)
startTimer(slot)
}
}
function release(slot: Slot): void {
slot.visible = Math.max(0, slot.visible - 1)
if (slot.visible === 0) {
stopTimer(slot)
// No consumer left: drop the snapshot so a later mount re-reads the truth
// instead of coloring rows from a stale answer.
slot.snapshot = null
slot.index = null
slot.indexSource = null
notify(slot)
}
}
/** The index for the current snapshot, rebuilt only when the snapshot changed. */
function indexOf(slot: Slot): Index {
if (slot.index === null || slot.indexSource !== slot.snapshot) {
slot.index = slot.snapshot === null ? { files: new Map(), dirs: new Set() } : buildIndex(slot.snapshot)
slot.indexSource = slot.snapshot
}
return slot.index
}
/**
* Force a re-read of one session's git status (after stage/commit/discard,
* or from a manual refresh). Queues one fetch when another is in flight.
* Every live key of that session is refreshed, so the tree and the changes
* page never disagree.
*/
export function invalidateGitStatus(sessionId: string): void {
for (const slot of slots.values()) {
if (slot.sessionId === sessionId) fetchSlot(slot)
}
}
/** The consumer-facing view of one key's status. */
export interface GitStatusView {
/** The last snapshot, or null before the first answer. */
snapshot: GitStatusResult | null
loading: boolean
error: boolean
/** Re-read now (queued when a fetch is already in flight). */
refresh(): void
/** The status of one absolute path, or undefined when clean/unknown. */
statusOf(absolutePath: string): GitFileStatus | undefined
/** Whether `absoluteDir` or anything under it has a change. */
dirHasChanges(absoluteDir: string): boolean
}
/**
* Subscribe to the shared git status of one session (optionally of one
* linked worktree). The poll runs while `visible` is true, at `pollMs`.
*/
export function useGitStatus(
scope: { sessionId: string; cwd?: string },
options: { worktree?: string; visible?: boolean; pollMs?: number } = {},
): GitStatusView {
const { worktree, visible = true, pollMs = 2_500 } = options
const slot = slotOf(scope.sessionId, scope.cwd, worktree)
const subscribe = useCallback((listener: () => void) => {
slot.listeners.add(listener)
return () => { slot.listeners.delete(listener) }
}, [slot])
useSyncExternalStore(subscribe, () => slot.version)
useEffect(() => {
slot.pollMs = pollMs
if (!visible) return undefined
retain(slot)
return () => { release(slot) }
}, [slot, visible, pollMs])
const refresh = useCallback(() => { fetchSlot(slot) }, [slot])
const statusOf = useCallback(
(absolutePath: string) => indexOf(slot).files.get(normPath(absolutePath)),
[slot],
)
const dirHasChanges = useCallback(
(absoluteDir: string) => indexOf(slot).dirs.has(normPath(absoluteDir)),
[slot],
)
return { snapshot: slot.snapshot, loading: slot.loading, error: slot.error, refresh, statusOf, dirHasChanges }
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The global UI kit's public face. Pages import from HERE (`./ui/index.ts`)
* so the component set is one reviewable surface; the modules behind it stay
* free to split further without touching call sites.
*/
export {
Chip, ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge,
type StatusTone,
} from './kit.tsx'
export {
invalidateGitStatus, statusOfXY, useGitStatus,
type GitFileStatus, type GitStatusView, type GitTone,
} from './git-status.ts'
+219
View File
@@ -0,0 +1,219 @@
/*
* The global kit's sheet. Token-only by contract: `tests/theme.spec.ts`
* scans every `src/client/**\/*.module.css` and rejects a `color:`
* declaration whose value does not dereference a `--dsw-*` / `--ds-*` token.
*
* Scale (see docs/plans/2026-09-27-files-changes-uiux.md §1): 12px body,
* 11px meta, 28px controls, 2/3/4/8px radii — the tasks page's native scale,
* which the files and changes pages are being cut to.
*/
/* ── IconButton ─────────────────────────────────────────────────────────── */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
border-radius: 3px;
}
.iconButtonMd {
width: 28px;
height: 28px;
}
.iconButtonSm {
width: 24px;
height: 24px;
border-radius: 3px;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
.iconButtonActive {
color: var(--dsw-alias-state-business-primary);
}
.iconButtonDanger {
color: var(--dsw-alias-state-error-primary);
}
/* ── Chip ───────────────────────────────────────────────────────────────── */
.chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: none;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
white-space: nowrap;
cursor: pointer;
}
.chip:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Rest / hover / selected are three different pictures: the selected chip
keeps a filled accent-tinted surface AND the accent edge, so a hovered
neighbour never reads as selected. */
.chipActive,
.chipActive:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
border-color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}
.chipLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.chipCount {
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
.chipActive .chipCount {
color: var(--dsw-alias-label-secondary);
}
/* ── SectionHeader ──────────────────────────────────────────────────────── */
.sectionHeader {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 28px;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-secondary);
}
.sectionLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sectionCount {
flex: none;
font: var(--dsw-font-xxxs-11);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
}
/* The band's one trailing action is pushed to the right edge. */
.sectionAction {
margin-left: auto;
display: inline-flex;
align-items: center;
flex: none;
}
/* ── Notice ─────────────────────────────────────────────────────────────── */
.notice {
box-sizing: border-box;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
}
.noticeWell {
padding: 4px 12px 8px;
}
.noticePage {
padding: 16px;
text-align: center;
}
.noticeInline {
padding: 0;
font-size: 10px;
line-height: 1.45;
}
.noticeError {
color: var(--dsw-alias-state-error-primary);
}
.noticeWarn {
color: var(--dsw-alias-state-warn-primary);
}
.noticeHint {
color: var(--dsw-alias-label-tertiary);
}
/* ── ConfirmDialog ──────────────────────────────────────────────────────── */
.confirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.confirmDesc[data-danger='true'] {
color: var(--dsw-alias-label-primary);
}
/* ── StatusBadge ────────────────────────────────────────────────────────── */
.statusBadge {
flex: none;
padding: 0 4px;
border-radius: 2px;
font: var(--dsw-font-xxxs-strong-11);
font-family: var(--ds-font-family-code, monospace);
color: var(--dsw-alias-label-secondary);
}
.statusBadge[data-tone='modified'] { color: var(--dsw-alias-state-warn-primary); }
.statusBadge[data-tone='added'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='untracked'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='deleted'] { color: var(--dsw-alias-state-error-primary); }
.statusBadge[data-tone='renamed'] { color: var(--dsw-alias-state-business-primary); }
.statusBadge[data-tone='conflict'] { color: var(--dsw-alias-state-error-primary); }
.statusBadge[data-tone='read'] { color: var(--dsw-alias-state-success-primary); }
.statusBadge[data-tone='write'] { color: var(--dsw-alias-state-business-primary); }
.statusBadge[data-tone='edit'] { color: var(--dsw-alias-state-warn-primary); }
.statusBadge[data-tone='neutral'] { color: var(--dsw-alias-label-tertiary); }
+188
View File
@@ -0,0 +1,188 @@
/**
* The plugin's GLOBAL UI atoms — the one place the files page, the changes
* page, the tasks page and every future page take their row chrome, badges,
* state lines and confirmations from.
*
* Why they exist: the repo carried 14 duplicated empty/error/hint classes
* across four stylesheets (two of them byte-identical), two copies of the
* Cancel/Confirm modal, five icon-button recipes (including a class-NAME
* collision: `.iconButton` was a 28px circle in one sheet and a 24px square
* in another) and five chip/toggle recipes. These components own those
* pictures ONCE, cut to the plugin's native scale (see
* `docs/plans/2026-09-27-files-changes-uiux.md` §1): 12px body, 11px meta,
* 28px controls/rows, 2/3/4/8px radii, one accent.
*
* Skin contract: every paint comes from `kit.module.css` and resolves to a
* `--dsw-*` / `--ds-*` token (`tests/theme.spec.ts` scans the sheet).
*/
import { type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './kit.module.css'
/**
* A 28px (`md`) or 24px (`sm`) square glyph button — the ONE icon button of
* the plugin. Callers that need a pressed state pass `active`; the danger
* tone only recolors the glyph/hover, never the geometry.
*/
export function IconButton(props: {
icon: ReactNode
/** Accessible name; also the default hover title. */
label: string
title?: string
onClick(event: MouseEvent<HTMLButtonElement>): void
size?: 'md' | 'sm'
active?: boolean
disabled?: boolean
danger?: boolean
className?: string | undefined
}): ReactNode {
const { icon, label, title, onClick, size = 'md', active, disabled, danger } = props
return (
<button
type="button"
className={clsx(css.iconButton, size === 'sm' ? css.iconButtonSm : css.iconButtonMd,
active === true && css.iconButtonActive, danger === true && css.iconButtonDanger, props.className)}
aria-label={label}
title={title ?? label}
disabled={disabled}
aria-pressed={active}
onClick={(event) => { onClick(event) }}
>
{icon}
</button>
)
}
/**
* A small toggle chip (filters, list switches). `count` renders as a trailing
* figure in the meta rung; the chip's rest / hover / selected states are three
* different pictures (a bare fill swap reads as hover).
*/
export function Chip(props: {
children: ReactNode
active?: boolean
count?: number
title?: string
disabled?: boolean
onClick(): void
className?: string | undefined
}): ReactNode {
return (
<button
type="button"
className={clsx(css.chip, props.active === true && css.chipActive, props.className)}
data-active={props.active === true ? 'true' : undefined}
aria-pressed={props.active === true}
title={props.title}
disabled={props.disabled}
onClick={() => { props.onClick() }}
>
<span className={css.chipLabel}>{props.children}</span>
{props.count !== undefined && <span className={css.chipCount}>{props.count}</span>}
</button>
)
}
/**
* A 28px section band: a label (with an optional count) that may carry ONE
* trailing action. The hairline under it is the page's only section divider.
*/
export function SectionHeader(props: {
label: ReactNode
count?: number
action?: ReactNode
className?: string | undefined
children?: ReactNode
}): ReactNode {
return (
<div className={clsx(css.sectionHeader, props.className)}>
<span className={css.sectionLabel}>{props.label}</span>
{props.count !== undefined && <span className={css.sectionCount}>{props.count}</span>}
{props.children}
{props.action !== undefined && <span className={css.sectionAction}>{props.action}</span>}
</div>
)
}
/**
* The plugin's state lines: empty / loading / error / warn / hint.
* `tone` picks the geometry (`page` = centered 16px block, `well` = the
* compact 4/12/8 line inside a list, `inline` = 10px micro copy).
*/
export function Notice(props: {
kind: 'empty' | 'loading' | 'error' | 'warn' | 'hint'
tone?: 'page' | 'well' | 'inline'
role?: string
className?: string | undefined
children: ReactNode
}): ReactNode {
const tone = props.tone ?? 'well'
return (
<div
className={clsx(css.notice, tone === 'page' ? css.noticePage : tone === 'inline' ? css.noticeInline : css.noticeWell,
props.kind === 'error' && css.noticeError, props.kind === 'warn' && css.noticeWarn,
(props.kind === 'hint' || props.kind === 'loading') && css.noticeHint, props.className)}
data-kind={props.kind}
role={props.role ?? (props.kind === 'error' ? 'alert' : undefined)}
>
{props.children}
</div>
)
}
/**
* The ONE confirmation dialog: a quiet single-question body and a
* Cancel/Confirm footer, with `busy` locking both actions while the action
* runs. Destructive callers pass `danger` so the primary button reads as one.
*/
export function ConfirmDialog(props: {
open: boolean
title: string
description: string
confirmLabel: string
cancelLabel: string
danger?: boolean
busy?: boolean
onConfirm(): void
onClose(): void
}): ReactNode {
return (
<Modal
open={props.open}
onClose={props.onClose}
title={props.title}
closeLabel={props.cancelLabel}
footer={(
<>
<Button variant="outline" disabled={props.busy} onClick={props.onClose}>{props.cancelLabel}</Button>
<Button variant="primary" disabled={props.busy} onClick={props.onConfirm}>{props.confirmLabel}</Button>
</>
)}
>
<p className={css.confirmDesc} data-danger={props.danger === true ? 'true' : undefined}>{props.description}</p>
</Modal>
)
}
/**
* The status vocabulary shared by git rows, session-op rows and diff kinds.
* `neutral` is the fallback for a kind the caller has no tone for.
*/
export type StatusTone =
| 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
| 'conflict' | 'read' | 'write' | 'edit' | 'neutral'
/** A compact status badge: a git letter, an op kind, a diff tag. */
export function StatusBadge(props: {
tone: StatusTone
title?: string
className?: string | undefined
children: ReactNode
}): ReactNode {
return (
<span className={clsx(css.statusBadge, props.className)} data-tone={props.tone} title={props.title}>
{props.children}
</span>
)
}
+13 -5
View File
@@ -13,7 +13,7 @@
* that one cached level and re-lists it. Folders the reader collapsed are
* un-watched, so a long session does not accumulate handles.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
/** One server frame: a stale directory, a watch verdict, or a refusal. */
interface FsWatchFrame {
@@ -47,15 +47,23 @@ const RETRY_MAX_MS = 15_000
export function useDirectoryWatch(options: DirectoryWatchOptions): void {
const { sessionId, root, dirs, onStale } = options
// The expanded set is a fresh array on every render, so neither the socket
// effect nor the staleness callback may depend on their identity.
const wantedRef = useRef<string[]>([])
wantedRef.current = root === undefined ? [...dirs] : [root, ...dirs]
// effect nor the staleness callback may depend on their identity. The
// CONTENT key (NUL-joined paths — no path can contain one) is what the
// arrays are derived from, so a re-render with the same expanded set
// rebuilds neither the wanted list nor the effect dependency.
const dirsKey = dirs.join('\u0000')
const wanted = useMemo(() => {
const listed = dirsKey === '' ? [] : dirsKey.split('\u0000')
return root === undefined ? listed : [root, ...listed]
}, [root, dirsKey])
const wantedRef = useRef(wanted)
wantedRef.current = wanted
const staleRef = useRef(onStale)
staleRef.current = onStale
/** Set by the socket effect; reconciles the host's watch set with the tree's. */
const reconcileRef = useRef<() => void>(() => {})
// One string per expanded-set change: a stable dependency for the effect below.
const wantedKey = wantedRef.current.join('\u0000')
const wantedKey = useMemo(() => wanted.join('\u0000'), [wanted])
useEffect(() => {
if (sessionId === undefined) return