diff --git a/README.md b/README.md index 085367b..dae57ea 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 -- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab +- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 4ed639f..ca4c5ee 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -359,7 +359,7 @@ ctx.effect(() => { | id | order | single | hidden | 用途 | |---|---|---|---|---| | `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移 | -| `git` | 20 | 是 | 否 | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:会话事件日志实时折叠的模型读写编辑记录,按文件分组、类型筛选、badge 计数);两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠) | +| `git` | 20 | 是 | 是(本轮文件操作数) | 「文件变动」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(原 Git 面板:staged/unstaged / 提交 / 历史 / worktree·子仓库选择)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按文件分组、类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),badge 读 tab 轮询写入的同步缓存。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab——落点由二级设置 `changesDiffFloat` 决定(默认**自由浮窗**居中弹出,可选面板下半 split),设置项见 tab descriptor 的 `settings.toggles` select | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `sidechat` | 35 | 否(`sidechat:`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) | | `terminal` | 40 | 否(`terminal:`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned::`),就地按 home scope 连 PTY;global 全会话可见、workspace 仅同 cwd;`tab.pin = { scope, homeCwd? }` 随会话持久化,渲染期解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`) | @@ -691,7 +691,7 @@ ctx.effect(() => ) ``` -> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 +> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` / `changesDiffFloat`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 ### 8.1 内置键 `workspaceFence`(工作区路径围栏) diff --git a/src/client/api.ts b/src/client/api.ts index 9391c4a..8958f63 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -9,6 +9,7 @@ import { encodeHtmlUrl } from '../html-route.ts' import type { LastActivity } from '../subagent-activity.ts' import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' +import type { SidebarSessionEvent } from '../context-types.ts' import type { BrowserProbeResult } from './browser.ts' /** One wire failure. */ @@ -258,6 +259,14 @@ export const api = { /** Full patch text of one commit (diff display for the history rows). */ gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) => call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal), + /** The session's file-tool events for the changes tab's session lens: the + * `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window), + * capped to the recent window host-side. The client runtime exposes no + * event-log face, so the lens polls this delta route. */ + changesOps: (scope: SessionScope, afterSeq?: number, signal?: AbortSignal) => + call<{ events: SidebarSessionEvent[]; lastSeq: number }>('changes.ops', scopePayload(scope, { + ...(afterSeq !== undefined && afterSeq > 0 ? { afterSeq } : {}), + }), signal), /** Discard the worktree changes of one file (the index is untouched). */ gitDiscard: (scope: SessionScope, path: string, worktree?: string) => call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })), diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index e8d514d..3bc1bdd 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -16,14 +16,13 @@ import { openSidebarFile } from '../intercept.tsx' import { EditorHost } from '../EditorHost.tsx' import { OpenWithSettings } from '../open-with-settings.tsx' import { lazyChunkComponent } from '../lazy-chunk.tsx' -import { ChangesTab } from '../changes/ChangesTab.tsx' -import { extractFileOps } from '../changes/ops.ts' +import { ChangesTab, opCountOf } from '../changes/ChangesTab.tsx' import { DiffTab } from '../DiffTab.tsx' import { SubagentView } from '../SubagentView.tsx' import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx' import { api } from '../api.ts' import { BrowserView } from '../BrowserView.tsx' -import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx' +import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx' import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts' import type { ComponentType } from 'react' import type { SessionScope } from '../api.ts' @@ -144,17 +143,42 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read // The unified changes tab (id kept as 'git' so persisted layouts keep // resolving): the Git lens is the former source-control panel; the // session lens is the former file-trace tab (PR #471). Both preview - // through one shared diff stack, and the badge counts this session's - // file ops (live from the event log — the git status itself needs a - // fetch, so it stays out of the badge). + // through one shared diff stack. The badge reads the op-count cache + // the tab's event poll publishes (the client ctx exposes no event + // log, and the git status needs a fetch — both stay out of the badge). id: 'git', title: () => t('changes'), icon: (size: number) => , order: 20, single: true, - badge: (ctx, scope) => { - const events = ctx.sessions.get(scope.sessionId)?.events - return events === undefined ? null : extractFileOps(events).length + badge: (_ctx, scope) => { + const count = opCountOf(scope.sessionId) + return count === undefined || count === 0 ? null : count + }, + // Declarative settings: the diff-open picker (free window vs docked + // pane) renders as an iconed select row under the changes card's gear + // in the Side card settings page. + settings: { + toggles: [{ + key: 'changesDiffFloat', + type: 'select', + title: () => t('changesDiffOpenTitle'), + desc: () => t('changesDiffOpenDesc'), + options: [ + { + value: true, + icon: (size: number) => , + title: () => t('changesDiffOpenFloat'), + desc: () => t('changesDiffOpenFloatDesc'), + }, + { + value: false, + icon: (size: number) => , + title: () => t('changesDiffOpenPane'), + desc: () => t('changesDiffOpenPaneDesc'), + }, + ], + }], }, component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => ( () + +/** The session's traced-op count as of the last poll (undefined before the + * tab has ever pulled; 0 hides the badge pill). */ +export function opCountOf(sessionId: string): number | undefined { + return opCounts.get(sessionId) +} + type Lens = 'git' | 'session' /** The persisted tab meta (JSON-serializable; rides the layout). */ @@ -36,6 +58,50 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT, ) + // ── 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. ──────────────────────────────────────── + const eventsRef = useRef([]) + const seqRef = useRef(0) + const pollGen = useRef(0) + const [opsError, setOpsError] = useState(false) + const [tick, setTick] = useState(0) + const pull = useCallback(async (): Promise => { + const generation = pollGen.current + try { + const { events, lastSeq } = await api.changesOps(scope, seqRef.current) + if (generation !== pollGen.current) return + if (events.length > 0) { + const merged = [...eventsRef.current, ...events] + eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged + } + if (lastSeq > seqRef.current) seqRef.current = lastSeq + opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length) + setOpsError(false) + setTick(value => value + 1) + } catch { + // Offline / route unavailable: keep the last fold; surface it inline + // only while nothing has ever loaded. + if (generation === pollGen.current) setOpsError(true) + } + }, [scope.sessionId, scope.cwd]) + useEffect(() => { + pollGen.current += 1 + eventsRef.current = [] + seqRef.current = 0 + setOpsError(false) + }, [scope.sessionId]) + useEffect(() => { + void pull() + if (!visible) return + const timer = window.setInterval(() => { void pull() }, 2_500) + return () => { window.clearInterval(timer) } + }, [visible, pull]) + // tick only forces the re-render; the fold reads the ref directly. + void tick + const ops = extractFileOps(eventsRef.current) + /** Persist a meta patch onto the tab (lens choice, pane height). */ const patchMeta = (patch: ChangesMeta): void => { ctx.get('betterSidebar')?.updateTab(tab.id, { @@ -55,8 +121,22 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen } /** Preview one session op (with its best-effort prior content snapshot). */ - const previewOp = (path: string, op: FileOp, prior: string | undefined): void => { - setPreview({ kind: 'op', path, op, prior }) + const previewOp = (path: string, op: FileOp): void => { + setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) }) + } + + /** Expand the current git preview into the dedicated diff tab: docked + * into the shell's diff pane, or floated as a free window centered on + * the viewport when the tab's diff-open setting asks for it (default). */ + const expandPreview = (): void => { + if (preview?.kind !== 'git') return + const diffTab = diffTabOf(preview.ref) + onOpenDiff?.(diffTab) + if (store.getPrefs().changesDiffFloat !== false) { + const x = Math.round(window.innerWidth / 2) + const y = Math.round(window.innerHeight / 2) + store.reduce(state => floatTab(state, diffTab.id, x, y)) + } } const previewKey = (target: ChangesPreview): string => target.kind === 'git' @@ -102,9 +182,8 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen ) : ( @@ -117,9 +196,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen height={paneHeight} onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }} onClose={() => { setPreview(null) }} - onExpand={() => { - if (preview.kind === 'git') onOpenDiff?.(diffTabOf(preview.ref)) - }} + onExpand={expandPreview} /> )} diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index d343ff1..afec62b 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -12,8 +12,8 @@ */ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import { - Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16, - IconTrashOutline16, Input, Menu, Modal, writeClipboard, + Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16, + IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts' import { api } from '../api.ts' @@ -418,7 +418,7 @@ export function GitLens(props: GitLensProps) { disabled={busy} onClick={() => { void stageEntry(entry, staged) }} > - {staged ? : } + {staged ? : } ) @@ -596,7 +596,7 @@ export function GitLens(props: GitLensProps) { : []), fileMenu?.staged === true ? { id: 'stage', label: t('unstage'), icon: } - : { id: 'stage', label: t('stage'), icon: }, + : { id: 'stage', label: t('stage'), icon: }, ...(fileMenu !== null && !isUntracked(fileMenu.entry) ? [{ id: 'discard', label: t('discard'), icon: , danger: true }] : []), diff --git a/src/client/changes/SessionLens.tsx b/src/client/changes/SessionLens.tsx index 298bde7..8b8e6a1 100644 --- a/src/client/changes/SessionLens.tsx +++ b/src/client/changes/SessionLens.tsx @@ -1,62 +1,34 @@ /** * The session lens of the changes tab: agent truth — every file the model - * read, wrote, or edited in this session (folded from the session event - * log), grouped by file, newest first, with kind filters. Clicking an op - * previews it in the tab's shared bottom pane (see {@link DiffPane}): writes - * and edits as line diffs, reads as a line-numbered content view, failures - * as their real error text. Live `tool/call` / `tool/result` events append - * while the tab is visible (bounded; paused and caught up when hidden). + * read, wrote, or edited in this session, grouped by file, newest first, + * with kind filters. Clicking an op previews it in the tab's shared bottom + * pane (see {@link DiffPane}): writes and edits as line diffs, reads as a + * line-numbered content view, failures as their real error text. The ops + * arrive pre-folded from the tab (which owns the event poll); this + * component is purely presentational. */ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import type { SidebarSessionEvent } from '../../context-types.ts' -import type { Context } from '../../context-types.ts' -import type { SessionScope } from '../api.ts' +import { useMemo, useState, type ReactNode } from 'react' import { relativeTime, t } from '../locales.ts' -import { extractFileOps, groupByFile, knownContentBefore, type FileOp, type FileOpKind } from './ops.ts' +import { groupByFile, type FileOp, type FileOpKind } from './ops.ts' import { formatBytes } from '../diff/rows.ts' import css from './changes.module.css' -/** Cap on accumulated events: the lens shows the recent window, not eternity. */ -const EVENTS_CAP = 4000 - /** The op-kind filter chips: 'all' or one concrete kind. */ type OpFilter = 'all' | FileOpKind export interface SessionLensProps { - ctx: Context - scope: SessionScope - /** Preview one op in the shared bottom pane (with its best-effort prior content). */ - onPreview: (path: string, op: FileOp, prior: string | undefined) => void + /** The folded file operations, newest first (the tab's poll owns them). */ + ops: readonly FileOp[] + /** Every poll failed and nothing loaded: an inline notice, not an empty + * state that would read as "no operations". */ + loadError: boolean + /** Preview one op in the shared bottom pane. */ + onPreview: (path: string, op: FileOp) => void /** The op currently previewed (row highlight); null when the pane is closed. */ selectedCallId: string | null - /** Fold events only while the tab is actually visible. */ - visible: boolean } -export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: SessionLensProps) { - // The event log snapshot seeds the lens once; live tool/call and - // tool/result events append (bounded) and re-extract while visible. - const eventsRef = useRef(null) - if (eventsRef.current === null) { - eventsRef.current = ctx.sessions.get(scope.sessionId)?.events ?? [] - } - const [tick, setTick] = useState(0) - useEffect(() => { - if (typeof ctx.on !== 'function') return - const off = ctx.on('session/event', (session: unknown, event: SidebarSessionEvent) => { - const sessionId = (session as { id?: unknown } | null)?.id - if (sessionId !== scope.sessionId) return - if (event.type !== 'tool/call' && event.type !== 'tool/result') return - const list = [...eventsRef.current ?? [], event] - eventsRef.current = list.length > EVENTS_CAP ? list.slice(list.length - EVENTS_CAP) : list - if (visible) setTick(value => value + 1) - }) - return () => { off() } - }, [ctx, scope.sessionId, visible]) - // Becoming visible again catches up on events appended while paused. - useEffect(() => { if (visible) setTick(value => value + 1) }, [visible]) - - const ops = useMemo(() => extractFileOps(eventsRef.current ?? []), [tick]) +export function SessionLens({ ops, loadError, onPreview, selectedCallId }: SessionLensProps) { const [filter, setFilter] = useState('all') const filteredOps = useMemo( () => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)), @@ -91,7 +63,8 @@ export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: {chip('read', t('changesRead'), counts.get('read') ?? 0)}
- {ops.length === 0 &&
{t('changesSessionEmpty')}
} + {loadError &&
{t('changesLoadError')}
} + {ops.length === 0 && !loadError &&
{t('changesSessionEmpty')}
} {ops.length > 0 && filteredOps.length === 0 &&
{t('changesFilterEmpty')}
} {[...groups.entries()].map(([path, fileOps]) => (
@@ -104,19 +77,21 @@ export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: data-op-kind={op.kind} data-op-error={op.isError ? 'true' : undefined} data-selected={selectedCallId === op.callId ? 'true' : undefined} - onClick={() => { onPreview(path, op, knownContentBefore(ops, path, op)) }} + onClick={() => { onPreview(path, op) }} > {t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')} - {relativeTime(new Date(op.time).toISOString())} {op.running && {t('changesRunning')}} {op.isError && {t('changesError')}} - {op.kind !== 'read' && !op.isError && ( - - {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)} - - )} + + {op.kind !== 'read' && !op.isError && ( + + {formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)} + + )} + {relativeTime(new Date(op.time).toISOString())} + ))}
diff --git a/src/client/changes/changes.module.css b/src/client/changes/changes.module.css index 17e83fa..1328393 100644 --- a/src/client/changes/changes.module.css +++ b/src/client/changes/changes.module.css @@ -140,8 +140,9 @@ color: var(--dsw-alias-label-secondary); } -/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts. */ -.gitBadge[data-letter='A'] { color: var(--dsw-alias-state-success-primary); } +/* 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); } @@ -316,19 +317,28 @@ .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: 8px; } -.filePath { display: block; color: var(--dsw-alias-label-secondary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 2px 4px; word-break: break-all; } +.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: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 + 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; } .opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; } .opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; } -.opSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; } +.opSize { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; font-family: var(--ds-font-family-code, monospace); } /* ── Shared preview pane ───────────────────────────────────────────────── */ @@ -341,7 +351,10 @@ .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); } -.diffPath { flex: 1; font-family: var(--ds-font-family-code, monospace); font-size: 11px; word-break: break-all; color: var(--dsw-alias-label-primary); min-width: 0; } +/* 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 { diff --git a/src/client/diff/DiffRows.tsx b/src/client/diff/DiffRows.tsx index 8316546..e40b877 100644 --- a/src/client/diff/DiffRows.tsx +++ b/src/client/diff/DiffRows.tsx @@ -206,6 +206,7 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
{ setExpandedFolds(prev => { diff --git a/src/client/diff/diff.module.css b/src/client/diff/diff.module.css index bdb59c8..ac706c3 100644 --- a/src/client/diff/diff.module.css +++ b/src/client/diff/diff.module.css @@ -43,13 +43,18 @@ .tokMacro { color: color-mix(in oklab, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-state-business-primary)); } /* Hunk-fold row: collapsed marker chip; expanded padding drops to 0 so the - revealed rows keep the x-alignment of hunk rows (they carry their own). */ -.foldRow { padding: 0 10px; cursor: pointer; } + revealed rows keep the x-alignment of hunk rows (they carry their own). + Only an expandable fold (enough hidden rows to be worth a click) reads as + interactive — the › chevron matches the multi-file document's disclosure. */ +.foldRow { padding: 0 10px; } +.foldRow[data-expandable='true'] { cursor: pointer; } +.foldRow[data-expandable='true']:hover { background: var(--dsw-alias-interactive-bg-hover); } .foldRow[data-expanded='true'] { padding: 0; } -.foldRow:hover { background: var(--dsw-alias-interactive-bg-hover); } -.foldMarker { display: inline-block; padding: 2px 6px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); font-style: italic; } .foldRow[data-expanded='true'] { cursor: default; } .foldRow[data-expanded='true'] .foldMarker { display: none; } +.foldMarker { display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); } +.foldRow[data-expandable='true'] .foldMarker { color: var(--dsw-alias-label-secondary); } +.foldRow[data-expandable='true'] .foldMarker::before { content: '›'; color: var(--dsw-alias-label-tertiary); transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } /* The huge-file overflow reveal under a capped hunk. */ .expand { display: block; width: calc(100% - 20px); margin: 4px 10px; padding: 4px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; cursor: pointer; } diff --git a/src/client/icons.tsx b/src/client/icons.tsx index ab165e1..bd728f6 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -161,3 +161,15 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => ( ) + +/** + * Free-window glyph in the app's outline style (1.5px stroke, currentColor): + * a background frame with a detached rounded mini-window floating over its + * top-right — the changes tab's "diff opens as a free window" setting. + */ +export const IconFloatWindowOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 2fecda1..8d4630d 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -145,6 +145,13 @@ export const ar: Record = { changesOpenDiffTab: 'فتح في تبويب diff منفصل', changesClosePreview: 'إغلاق المعاينة', changesResizePreview: 'تغيير حجم المعاينة', + changesDiffOpenTitle: 'يفتح الفرق كـ', + changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق', + changesDiffOpenFloat: 'نافذة عائمة', + changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق', + changesDiffOpenPane: 'لوحة', + changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)', + changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن', staged: 'مُدرج', unstaged: 'غير مُدرج', cancel: 'إلغاء', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index cb92c75..19faf81 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -130,6 +130,13 @@ export const de: Record = { changesOpenDiffTab: 'In eigenem Tab öffnen', changesClosePreview: 'Vorschau schließen', changesResizePreview: 'Vorschauhöhe anpassen', + changesDiffOpenTitle: 'Diff öffnet als', + changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert', + changesDiffOpenFloat: 'Freies Fenster', + changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten', + changesDiffOpenPane: 'Bereich', + changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)', + changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar', staged: 'Gestaged', unstaged: 'Nicht gestaged', cancel: 'Abbrechen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index cfa4434..45fe008 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -137,6 +137,13 @@ export const fr: Record = { changesOpenDiffTab: 'Ouvrir dans un onglet diff', changesClosePreview: 'Fermer l\'aperçu', changesResizePreview: 'Redimensionner l\'aperçu', + changesDiffOpenTitle: 'Le diff s\'ouvre en', + changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff', + changesDiffOpenFloat: 'Fenêtre flottante', + changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan', + changesDiffOpenPane: 'Panneau', + changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)', + changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles', staged: 'En scène', unstaged: 'Hors scène', cancel: 'Annuler', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 36ef5e9..f4a2925 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -144,6 +144,13 @@ export const hi: Record = { changesOpenDiffTab: 'अलग diff टैब में खोलें', changesClosePreview: 'पूर्वावलोकन बंद करें', changesResizePreview: 'पूर्वावलोकन का आकार बदलें', + changesDiffOpenTitle: 'diff खुलता है', + changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है', + changesDiffOpenFloat: 'मुक्त विंडो', + changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें', + changesDiffOpenPane: 'पैनल', + changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)', + changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं', staged: 'स्टेज किया', unstaged: 'अनस्टेज', cancel: 'रद्द करें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 9f859dc..35077d4 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -142,6 +142,13 @@ export const id: Record = { changesOpenDiffTab: 'Buka di tab diff terpisah', changesClosePreview: 'Tutup pratinjau', changesResizePreview: 'Ubah ukuran pratinjau', + changesDiffOpenTitle: 'Diff terbuka sebagai', + changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff', + changesDiffOpenFloat: 'Jendela bebas', + changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas', + changesDiffOpenPane: 'Panel', + changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)', + changesLoadError: 'Catatan berkas sesi sedang tidak tersedia', staged: 'Staged', unstaged: 'Unstaged', cancel: 'Batal', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 7ca8dc0..81e6a2f 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -135,6 +135,13 @@ export const it: Record = { changesOpenDiffTab: 'Apri in una scheda diff', changesClosePreview: 'Ridimensiona l\'anteprima', changesResizePreview: 'Ridimensiona anteprima', + changesDiffOpenTitle: 'Il diff si apre come', + changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff', + changesDiffOpenFloat: 'Finestra libera', + changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano', + changesDiffOpenPane: 'Pannello', + changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)', + changesLoadError: 'I record dei file della sessione non sono disponibili', staged: 'In stage', unstaged: 'Non in stage', cancel: 'Annulla', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 1abf67e..fb3d8df 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -144,6 +144,13 @@ export const ja: Record = { changesOpenDiffTab: '独立タブで開く', changesClosePreview: 'プレビューを閉じる', changesResizePreview: 'プレビューの高さを変更', + changesDiffOpenTitle: 'diff の展開先', + changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか', + changesDiffOpenFloat: 'フリーウィンドウ', + changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定', + changesDiffOpenPane: 'ペイン', + changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)', + changesLoadError: 'セッションのファイル記録は現在利用できません', staged: 'ステージ済み', unstaged: '未ステージ', cancel: 'キャンセル', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 4ae68fe..c4136ef 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -136,6 +136,13 @@ export const ko: Record = { changesOpenDiffTab: '별도 탭에서 열기', changesClosePreview: '미리보기 닫기', changesResizePreview: '미리보기 높이 조정', + changesDiffOpenTitle: 'diff 열기 위치', + changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳', + changesDiffOpenFloat: '자유 창', + changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위', + changesDiffOpenPane: '패널', + changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)', + changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다', staged: '스테이징됨', unstaged: '스테이징 안 됨', cancel: '취소', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 99734a7..fbab59f 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -142,6 +142,13 @@ export const nl: Record = { changesOpenDiffTab: 'In eigen diff-tabblad openen', changesClosePreview: 'Voorbeeld sluiten', changesResizePreview: 'Voorbeeld vergroten/verkleinen', + changesDiffOpenTitle: 'Diff opent als', + changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst', + changesDiffOpenFloat: 'Vrij venster', + changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden', + changesDiffOpenPane: 'Paneel', + changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)', + changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar', staged: 'Gestaged', unstaged: 'Niet gestaged', cancel: 'Annuleren', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index f24d3ed..9600fd6 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -146,6 +146,13 @@ export const pl: Record = { changesOpenDiffTab: 'Otwórz w osobnej karcie', changesClosePreview: 'Zamknij podgląd', changesResizePreview: 'Zmień wysokość podglądu', + changesDiffOpenTitle: 'Diff otwiera się jako', + changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff', + changesDiffOpenFloat: 'Odrębne okno', + changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu', + changesDiffOpenPane: 'Panel', + changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)', + changesLoadError: 'Zapisy plików sesji są teraz niedostępne', staged: 'W indeksie', unstaged: 'Poza indeksem', cancel: 'Anuluj', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 143105a..90c8462 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -127,6 +127,13 @@ export const pt: Record = { changesOpenDiffTab: 'Abrir em uma aba de diff', changesClosePreview: 'Fechar prévia', changesResizePreview: 'Ajustar altura da prévia', + changesDiffOpenTitle: 'O diff abre como', + changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff', + changesDiffOpenFloat: 'Janela livre', + changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo', + changesDiffOpenPane: 'Painel', + changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)', + changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento', staged: 'Preparadas', unstaged: 'Não preparadas', cancel: 'Cancelar', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 33c5c2a..bac9aab 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -140,6 +140,13 @@ export const ru: Record = { changesOpenDiffTab: 'Открыть в отдельной вкладке', changesClosePreview: 'Закрыть просмотр', changesResizePreview: 'Изменить высоту просмотра', + changesDiffOpenTitle: 'Diff открывается как', + changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff', + changesDiffOpenFloat: 'Свободное окно', + changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех', + changesDiffOpenPane: 'Панель', + changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)', + changesLoadError: 'Записи файлов сессии сейчас недоступны', staged: 'В индексе', unstaged: 'Не в индексе', cancel: 'Отмена', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 1f7f16a..4e2426c 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -127,6 +127,13 @@ export const sv: Record = { changesOpenDiffTab: 'Öppna i egen diff-flik', changesClosePreview: 'Stäng förhandsvisning', changesResizePreview: 'Ändra storlek på förhandsvisningen', + changesDiffOpenTitle: 'Diff öppnas som', + changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen', + changesDiffOpenFloat: 'Fritt fönster', + changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst', + changesDiffOpenPane: 'Panel', + changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)', + changesLoadError: 'Sessionens filposter är inte tillgängliga just nu', staged: 'Köade', unstaged: 'Avköade', cancel: 'Avbryt', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 8bafaad..0245b39 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -144,6 +144,13 @@ export const th: Record = { changesOpenDiffTab: 'เปิดในแท็บ diff แยก', changesClosePreview: 'ปิดตัวอย่าง', changesResizePreview: 'ปรับขนาดตัวอย่าง', + changesDiffOpenTitle: 'diff เปิดเป็น', + changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด', + changesDiffOpenFloat: 'หน้าต่างลอย', + changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด', + changesDiffOpenPane: 'แพเนล', + changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)', + changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้', staged: 'Staged', unstaged: 'Unstaged', cancel: 'ยกเลิก', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 658809f..48bcf97 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -144,6 +144,13 @@ export const tr: Record = { changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç', changesClosePreview: 'Önizlemeyi kapat', changesResizePreview: 'Önizlemeyi boyutlandır', + changesDiffOpenTitle: 'diff açılır', + changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar', + changesDiffOpenFloat: 'Serbest pencere', + changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut', + changesDiffOpenPane: 'Panel', + changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)', + changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor', staged: 'Sahnelendi', unstaged: 'Sahnelenmedi', cancel: 'İptal', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 46eb488..0926641 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -144,6 +144,13 @@ export const vi: Record = { changesOpenDiffTab: 'Mở trong tab diff riêng', changesClosePreview: 'Đóng xem trước', changesResizePreview: 'Điều chỉnh kích thước xem trước', + changesDiffOpenTitle: 'diff mở thành', + changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu', + changesDiffOpenFloat: 'Cửa sổ tự do', + changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng', + changesDiffOpenPane: 'Bảng', + changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)', + changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng', staged: 'Đã stage', unstaged: 'Chưa stage', cancel: 'Hủy', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 9d5a1e4..7644961 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -159,6 +159,13 @@ export const zhHK: Record = { changesOpenDiffTab: '在獨立分頁中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', + changesDiffOpenTitle: '差異展開方式', + changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡', + changesDiffOpenFloat: '浮動視窗', + changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', + changesDiffOpenPane: '面板', + changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', + changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', cancel: '取消', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 7dcd489..b99213d 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -159,6 +159,13 @@ export const zhMO: Record = { changesOpenDiffTab: '在獨立分頁中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', + changesDiffOpenTitle: '差異展開方式', + changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡', + changesDiffOpenFloat: '浮動視窗', + changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', + changesDiffOpenPane: '面板', + changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', + changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', cancel: '取消', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 23fa78f..ff8d965 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -159,6 +159,13 @@ export const zhTW: Record = { changesOpenDiffTab: '在獨立頁籤中開啟', changesClosePreview: '關閉預覽', changesResizePreview: '調整預覽高度', + changesDiffOpenTitle: '差異展開方式', + changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡', + changesDiffOpenFloat: '浮動視窗', + changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂', + changesDiffOpenPane: '面板', + changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)', + changesLoadError: '工作階段檔案記錄暫不可用', staged: '已暫存', unstaged: '未暫存', cancel: '取消', diff --git a/src/client/locales.ts b/src/client/locales.ts index 06a3215..d1af57f 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -150,6 +150,13 @@ export const zh = { changesOpenDiffTab: '在独立页签中打开', changesClosePreview: '关闭预览', changesResizePreview: '调整预览高度', + changesDiffOpenTitle: '差异展开方式', + changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里', + changesDiffOpenFloat: '浮窗', + changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶', + changesDiffOpenPane: '面板', + changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)', + changesLoadError: '会话文件记录暂不可用', staged: '已暂存', unstaged: '未暂存', cancel: '取消', @@ -533,6 +540,13 @@ export const en: Record = { changesOpenDiffTab: 'Open in a diff tab', changesClosePreview: 'Close preview', changesResizePreview: 'Resize preview', + changesDiffOpenTitle: 'Diff opens as', + changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff', + changesDiffOpenFloat: 'Free window', + changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top', + changesDiffOpenPane: 'Pane', + changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)', + changesLoadError: 'Session file records are unavailable right now', staged: 'Staged', unstaged: 'Unstaged', cancel: 'Cancel', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 4c9335e..be4d482 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -82,6 +82,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { editorExplorer: typeof record.editorExplorer === 'boolean' ? record.editorExplorer : SIDEBAR_PREFS_DEFAULTS.editorExplorer, + changesDiffFloat: typeof record.changesDiffFloat === 'boolean' + ? record.changesDiffFloat + : SIDEBAR_PREFS_DEFAULTS.changesDiffFloat, workspaceFence: typeof record.workspaceFence === 'boolean' ? record.workspaceFence : SIDEBAR_PREFS_DEFAULTS.workspaceFence, diff --git a/src/config.ts b/src/config.ts index 18c8a30..4f1b227 100644 --- a/src/config.ts +++ b/src/config.ts @@ -128,6 +128,7 @@ export const PrefsSchema: z = z.object({ terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), interceptOpenPath: z.boolean().default(true), editorExplorer: z.boolean().default(false), + changesDiffFloat: z.boolean().default(true), workspaceFence: z.boolean().default(true), terminalShell: z.string().default(''), terminalShellArgs: z.string().default(''), diff --git a/src/index.ts b/src/index.ts index 9a457d5..ae23c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path' import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import { WebSocket, WebSocketServer } from 'ws' -import type { Context, SidebarHttpRequest } from './context-types.ts' +import type { Context, SidebarHttpRequest, SidebarSessionEvent } from './context-types.ts' import { Config, PrefsSchema, @@ -455,6 +455,44 @@ function buildApi( const rev = requireString(payload, 'rev') return { content: await git.show(cwd, rev, path, repoRoot) } }, + // The session's file-tool events for the changes tab's session lens + // (and its badge): the CLIENT runtime's sessions face has no event-log + // access, so the events cross the wire here — live session log first, + // the persisted logical log for not-yet-hydrated sessions. Only the + // two event types the lens folds are sent, narrowed to `seq > afterSeq` + // so polling is a small delta, with the same recent-window cap the + // client accumulator applies. + 'changes.ops': async (payload) => { + const sessionId = requireString(payload, 'sessionId') + const rawAfter = (payload as { afterSeq?: unknown } | null)?.afterSeq + if (rawAfter !== undefined + && (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) { + throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer') + } + // An absent cursor means "from the very first event" — a session whose + // log opens on a tool event (subagent seeds do) carries seq 0, which a + // literal `> 0` comparison would drop, so the absent case floors at -1. + const afterSeq = rawAfter ?? -1 + let events: readonly SidebarSessionEvent[] | undefined = ctx.sessions.get(sessionId)?.events + if (events === undefined) { + const persistence = ctx.get('sessionPersistence') + if (persistence !== undefined) { + try { + events = (await persistence.inspect(sessionId)).events + } catch { + // Cold read unavailable (session never persisted): an empty + // window is the honest answer, not a wire error. + } + } + } + if (events === undefined) return { events: [], lastSeq: Math.max(afterSeq, 0) } + const CHANGES_EVENTS_CAP = 4000 + const filtered = events.filter( + event => (event.type === 'tool/call' || event.type === 'tool/result') && event.seq > afterSeq, + ) + const window = filtered.length > CHANGES_EVENTS_CAP ? filtered.slice(filtered.length - CHANGES_EVENTS_CAP) : filtered + return { events: window, lastSeq: window.at(-1)?.seq ?? afterSeq } + }, // Release a terminal immediately. The WebSocket close frame already does // this while the socket is open; this route covers the tab-close that // happens while the socket is down (reconnect loop), so a closed tab can diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 2f7f5be..a40a212 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -77,6 +77,14 @@ export interface SidebarPrefs { * Side card settings; off restores the pre-merge editor exactly. */ editorExplorer: boolean + /** + * Where the changes tab's "expand to a diff tab" action lands the diff: + * true (default) floats it as a free window centered on the viewport; + * false docks it into the shell's diff pane below the source panel (the + * pre-float behavior). The select lives under the changes card's gear in + * the Side card settings. + */ + changesDiffFloat: boolean /** * Whether the sidebar's filesystem routes enforce the workspace fence: * every client-supplied path must resolve (through symlinks) inside the @@ -267,6 +275,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, editorExplorer: false, + changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 4e78ec1..f14ca3f 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -44,6 +44,20 @@ describe('built-in tab registrations', () => { expect(changes?.component).toBeDefined() }) + it('the changes tab declares the diff-open picker (free window default)', () => { + const { service } = setup() + const toggles = service.getTab('git')?.settings?.toggles ?? [] + expect(toggles.map(t => t.key)).toEqual(['changesDiffFloat']) + const picker = toggles[0] + expect(picker?.type).toBe('select') + expect(picker?.title).toBeDefined() + expect(picker?.desc).toBeDefined() + const options = picker?.options ?? [] + // Float first (the default the reducer and prefs ship), pane second. + expect(options.map(o => o.value)).toEqual([true, false]) + expect(options.every(o => o.icon !== undefined && o.title !== undefined && o.desc !== undefined)).toBe(true) + }) + it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => { const { service } = setup() expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff']) diff --git a/tests/changes-tab.spec.tsx b/tests/changes-tab.spec.tsx index d7f876b..8187ce7 100644 --- a/tests/changes-tab.spec.tsx +++ b/tests/changes-tab.spec.tsx @@ -1,15 +1,16 @@ /** * The unified changes tab shell: the lens switcher swaps the Git lens for - * the session lens, and a Git-lens file row opens the shared preview pane + * the session lens, a Git-lens file row opens the shared preview pane * (loaded through the mocked git API, rendered by the shared DiffFiles - * stack) instead of minting a diff tab directly. + * stack), and the session ops ride the mocked `changes.ops` poll — the + * event poll, the op fold and the badge cache all live in the tab. */ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' -import { ChangesTab } from '../src/client/changes/ChangesTab.tsx' +import { ChangesTab, opCountOf } from '../src/client/changes/ChangesTab.tsx' import { createSidebarStore } from '../src/client/state.ts' import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.ts' import type { Context } from '../src/context-types.ts' @@ -19,16 +20,21 @@ import type { SidebarTab } from '../src/client/state.ts' const MAIN = 'C:/repo/main' +/** One synthetic tool/call + tool/result pair (write of one file). */ +function writeEvents(seq: number, path: string): Array<{ type: string; seq: number; time: number; data: Record }> { + return [ + { type: 'tool/call', seq, time: seq, data: { name: 'write', callId: `w${seq}`, arguments: JSON.stringify({ file_path: path, content: 'body' }) } }, + { type: 'tool/result', seq: seq + 1, time: seq + 1, data: { message: { source: { kind: 'tool', callId: `w${seq}` }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'ok' }] }] } } }, + ] +} + function fakeContext(): Context { return { - sessions: { get: () => undefined }, get: () => undefined, - on: () => () => { /* no-op */ }, } as unknown as Context } -function mount(root: Root): void { - const tab: SidebarTab = { id: 'git', type: 'git', title: 'Changes' } +function mount(root: Root, tab: SidebarTab = { id: 'git', type: 'git', title: 'Changes' }): void { act(() => { root.render(createElement(ChangesTab, { ctx: fakeContext(), @@ -108,21 +114,30 @@ describe('ChangesTab', () => { } }) - it('switches to the session lens, which shows its empty state without ops', async () => { + it('folds the polled session events into session-lens rows and the badge cache', async () => { mockGit([]) + const ops = vi.spyOn(api, 'changesOps') + .mockResolvedValue({ events: [...writeEvents(1, 'src/a.ts'), ...writeEvents(3, 'src/b.ts')], lastSeq: 4 }) + const container = document.createElement('div') document.body.append(container) const root: Root = createRoot(container) try { mount(root) await flushEffects() + + // The tab pulled once on mount and published the op count. + expect(ops).toHaveBeenCalled() + expect(opCountOf('session')).toBe(2) + const group = container.querySelector('[role="group"]') - expect(group).not.toBeNull() const buttons = [...group!.querySelectorAll('button')] await act(async () => { buttons[1]!.click() }) - // No session events in the fake context: the session lens empty state - // (locale-agnostic — the test env may resolve zh or en). - expect(container.textContent).toMatch(/本会话还没有文件操作|No file operations/) + // Two files grouped, newest first; the write chip carries its count. + expect(container.textContent).toContain('src/b.ts') + expect(container.textContent).toContain('src/a.ts') + const chips = [...container.querySelectorAll('button[aria-pressed]')] + expect(chips.some(chip => /写入\s*2|Write\s*2/.test(chip.textContent ?? ''))).toBe(true) // The git lens (its branch picker) is unmounted by the switch. expect(container.querySelector('select')).toBeNull() } finally { diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 9bc0e90..ffe16fc 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -97,6 +97,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index a80d643..449f55b 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -42,7 +42,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, - editorExplorer: false, + editorExplorer: false, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', @@ -76,7 +76,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, - editorExplorer: false, + editorExplorer: false, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', @@ -110,7 +110,7 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, - editorExplorer: false, + editorExplorer: false, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', @@ -282,9 +282,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -320,7 +320,7 @@ describe('side card preferences', () => { it('skips the default seed tab when the editor (files window) type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) store.setSession('no-editor') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -330,7 +330,7 @@ describe('side card preferences', () => { // editorExplorer modes. for (const editorExplorer of [true, false]) { const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) openStore.setSession(`with-editor-${editorExplorer}`) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['editor']) @@ -340,7 +340,7 @@ describe('side card preferences', () => { it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => { for (const editorExplorer of [true, false]) { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession(`fresh-${editorExplorer}`) const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(tabs).toHaveLength(1) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index e5358f3..990edad 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -955,6 +955,7 @@ describe('side card settings routes', () => { browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', + changesDiffFloat: true, // The enable-switch maps default to {} (everything on). tabsEnabled: {}, viewersEnabled: {},