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:
+13
-146
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+86
-84
@@ -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}
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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 */ })}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
+712
-598
File diff suppressed because it is too large
Load Diff
@@ -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}>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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); }
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user