fix(changes): 会话事件改走宿主 changes.ops 路由 + diff 展开二级设置 + 视觉迭代
CI 修复(plugin-mount 的 ctx.sessions.get TypeError ×1003):
- 客户端 runtime 无事件日志面——badge/SessionLens 原在客户端直读
ctx.sessions.get(id).events / ctx.on('session/event'),全部不可达。
- 新增宿主路由 changes.ops:live 会话日志优先,冷会话回放
sessionPersistence.inspect;afterSeq 增量(缺省游标含 seq 0,
显式游标为 seq > afterSeq,同 sidechat.events 约定),窗口上限
4000 与客户端累加器一致。
- ChangesTab 轮询(mount 一拉 + visible 2.5s 增量)折入 ops,写
模块级 opCounts 缓存供 badge 同步读取;SessionLens 改纯展示。
diff 展开二级设置(changesDiffFloat,默认浮窗):
- prefs 四处接线 + git tab settings select(浮窗/面板,图标选项卡)
- 预览面板「展开为 diff tab」按设置落点:浮窗居中弹出或源 pane
下方 split;i18n 全 21 词典同步。
视觉迭代(真机挂载截图审查后调整):
- Session 视角:kind 徽章为左轨,大小+相对时间右对齐元列;失败
操作行红色内嵌条;文件组头降为三级标签并加组间距。
- Git 视角:untracked '?' 徽章与新增同绿色;stage 按钮改 plus 图标。
- diff 折叠行:可展开折叠带 › 箭头 + 次级对比度,静态 gap 不再
显示 pointer/hover;预览头路径改尾部截断(RTL 技巧,保文件名)。
验证:typecheck ✓ / vitest 1123 ✓ / pnpm test:mount(钉 alpha.2 CLI)
14/14 ✓;真机挂载截图(badge=5、双视角、浮窗展开)0 console error。
This commit is contained in:
@@ -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 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
|
||||
@@ -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:<uuid>`,按 `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:<n>`) | 否 | 终端。v0.17.0+ 右键「固定到工作区/全局」:跨会话不消失,TabBar 内联虚拟 Tab(`pinned:<homeSessionId>:<tabId>`),就地按 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`(工作区路径围栏)
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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) => <IconDiffOutline16 size={size} />,
|
||||
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) => <IconFloatWindowOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenFloat'),
|
||||
desc: () => t('changesDiffOpenFloatDesc'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
|
||||
title: () => t('changesDiffOpenPane'),
|
||||
desc: () => t('changesDiffOpenPaneDesc'),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
|
||||
<ChangesTab
|
||||
|
||||
@@ -3,23 +3,45 @@
|
||||
* (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 can expand into the dedicated diff tab.
|
||||
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
|
||||
* docked in a pane, or floated as a free window per the tab's setting.
|
||||
* 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.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SidebarSessionEvent } from '../../context-types.ts'
|
||||
import type { TabComponentProps } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import type { SidebarDiffRef } from '../state.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||
import { GitLens } from './GitLens.tsx'
|
||||
import { SessionLens } from './SessionLens.tsx'
|
||||
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
|
||||
import type { FileOp } from './ops.ts'
|
||||
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
|
||||
import css from './changes.module.css'
|
||||
|
||||
/** The default preview pane height (px) before the first drag. */
|
||||
const PANE_HEIGHT_DEFAULT = 300
|
||||
|
||||
/** Cap on accumulated events: the lens shows the recent window, not eternity
|
||||
* (the host enforces the same bound per response). */
|
||||
const EVENTS_CAP = 4000
|
||||
|
||||
/** Live op count per session: the tab's poller writes, the badge reads (a
|
||||
* badge cannot fetch — it must resolve synchronously during render). */
|
||||
const opCounts = new Map<string, number>()
|
||||
|
||||
/** 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<readonly SidebarSessionEvent[]>([])
|
||||
const seqRef = useRef(0)
|
||||
const pollGen = useRef(0)
|
||||
const [opsError, setOpsError] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
const pull = useCallback(async (): Promise<void> => {
|
||||
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
|
||||
)
|
||||
: (
|
||||
<SessionLens
|
||||
ctx={ctx}
|
||||
scope={scope}
|
||||
visible={visible}
|
||||
ops={ops}
|
||||
loadError={opsError && ops.length === 0}
|
||||
onPreview={previewOp}
|
||||
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
|
||||
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -596,7 +596,7 @@ export function GitLens(props: GitLensProps) {
|
||||
: []),
|
||||
fileMenu?.staged === true
|
||||
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
||||
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
|
||||
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
|
||||
...(fileMenu !== null && !isUntracked(fileMenu.entry)
|
||||
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
|
||||
: []),
|
||||
|
||||
@@ -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<readonly SidebarSessionEvent[] | null>(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<OpFilter>('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)}
|
||||
</div>
|
||||
<div className={css.sessionList}>
|
||||
{ops.length === 0 && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
|
||||
{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>}
|
||||
{[...groups.entries()].map(([path, fileOps]) => (
|
||||
<div key={path} className={css.fileGroup}>
|
||||
@@ -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) }}
|
||||
>
|
||||
<span className={css.opKind} data-kind={op.kind}>
|
||||
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
|
||||
</span>
|
||||
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
|
||||
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
|
||||
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
|
||||
{op.kind !== 'read' && !op.isError && (
|
||||
<span className={css.opSize}>
|
||||
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.opMeta}>
|
||||
{op.kind !== 'read' && !op.isError && (
|
||||
<span className={css.opSize}>
|
||||
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -206,6 +206,7 @@ export function DiffRows({ segments, lang }: DiffRowsProps) {
|
||||
<div
|
||||
key={`fold-${String(segIndex)}`}
|
||||
className={css.foldRow}
|
||||
data-expandable="true"
|
||||
data-expanded={isExpanded ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setExpandedFolds(prev => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -161,3 +161,15 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -145,6 +145,13 @@ export const ar: Record<string, string> = {
|
||||
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
|
||||
changesClosePreview: 'إغلاق المعاينة',
|
||||
changesResizePreview: 'تغيير حجم المعاينة',
|
||||
changesDiffOpenTitle: 'يفتح الفرق كـ',
|
||||
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
|
||||
changesDiffOpenFloat: 'نافذة عائمة',
|
||||
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
|
||||
changesDiffOpenPane: 'لوحة',
|
||||
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
|
||||
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
|
||||
staged: 'مُدرج',
|
||||
unstaged: 'غير مُدرج',
|
||||
cancel: 'إلغاء',
|
||||
|
||||
@@ -130,6 +130,13 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -137,6 +137,13 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -144,6 +144,13 @@ export const hi: Record<string, string> = {
|
||||
changesOpenDiffTab: 'अलग diff टैब में खोलें',
|
||||
changesClosePreview: 'पूर्वावलोकन बंद करें',
|
||||
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
|
||||
changesDiffOpenTitle: 'diff खुलता है',
|
||||
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
|
||||
changesDiffOpenFloat: 'मुक्त विंडो',
|
||||
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
|
||||
changesDiffOpenPane: 'पैनल',
|
||||
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
|
||||
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
|
||||
staged: 'स्टेज किया',
|
||||
unstaged: 'अनस्टेज',
|
||||
cancel: 'रद्द करें',
|
||||
|
||||
@@ -142,6 +142,13 @@ export const id: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -135,6 +135,13 @@ export const it: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -144,6 +144,13 @@ export const ja: Record<string, string> = {
|
||||
changesOpenDiffTab: '独立タブで開く',
|
||||
changesClosePreview: 'プレビューを閉じる',
|
||||
changesResizePreview: 'プレビューの高さを変更',
|
||||
changesDiffOpenTitle: 'diff の展開先',
|
||||
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
|
||||
changesDiffOpenFloat: 'フリーウィンドウ',
|
||||
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
|
||||
changesDiffOpenPane: 'ペイン',
|
||||
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
|
||||
changesLoadError: 'セッションのファイル記録は現在利用できません',
|
||||
staged: 'ステージ済み',
|
||||
unstaged: '未ステージ',
|
||||
cancel: 'キャンセル',
|
||||
|
||||
@@ -136,6 +136,13 @@ export const ko: Record<string, string> = {
|
||||
changesOpenDiffTab: '별도 탭에서 열기',
|
||||
changesClosePreview: '미리보기 닫기',
|
||||
changesResizePreview: '미리보기 높이 조정',
|
||||
changesDiffOpenTitle: 'diff 열기 위치',
|
||||
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
|
||||
changesDiffOpenFloat: '자유 창',
|
||||
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
|
||||
changesDiffOpenPane: '패널',
|
||||
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
|
||||
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
|
||||
staged: '스테이징됨',
|
||||
unstaged: '스테이징 안 됨',
|
||||
cancel: '취소',
|
||||
|
||||
@@ -142,6 +142,13 @@ export const nl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -146,6 +146,13 @@ export const pl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -127,6 +127,13 @@ export const pt: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -140,6 +140,13 @@ export const ru: Record<string, string> = {
|
||||
changesOpenDiffTab: 'Открыть в отдельной вкладке',
|
||||
changesClosePreview: 'Закрыть просмотр',
|
||||
changesResizePreview: 'Изменить высоту просмотра',
|
||||
changesDiffOpenTitle: 'Diff открывается как',
|
||||
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
|
||||
changesDiffOpenFloat: 'Свободное окно',
|
||||
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
|
||||
changesDiffOpenPane: 'Панель',
|
||||
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
|
||||
changesLoadError: 'Записи файлов сессии сейчас недоступны',
|
||||
staged: 'В индексе',
|
||||
unstaged: 'Не в индексе',
|
||||
cancel: 'Отмена',
|
||||
|
||||
@@ -127,6 +127,13 @@ export const sv: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -144,6 +144,13 @@ export const th: Record<string, string> = {
|
||||
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
|
||||
changesClosePreview: 'ปิดตัวอย่าง',
|
||||
changesResizePreview: 'ปรับขนาดตัวอย่าง',
|
||||
changesDiffOpenTitle: 'diff เปิดเป็น',
|
||||
changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด',
|
||||
changesDiffOpenFloat: 'หน้าต่างลอย',
|
||||
changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด',
|
||||
changesDiffOpenPane: 'แพเนล',
|
||||
changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)',
|
||||
changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้',
|
||||
staged: 'Staged',
|
||||
unstaged: 'Unstaged',
|
||||
cancel: 'ยกเลิก',
|
||||
|
||||
@@ -144,6 +144,13 @@ export const tr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -144,6 +144,13 @@ export const vi: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -159,6 +159,13 @@ export const zhHK: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立分頁中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -159,6 +159,13 @@ export const zhMO: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立分頁中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -159,6 +159,13 @@ export const zhTW: Record<string, string> = {
|
||||
changesOpenDiffTab: '在獨立頁籤中開啟',
|
||||
changesClosePreview: '關閉預覽',
|
||||
changesResizePreview: '調整預覽高度',
|
||||
changesDiffOpenTitle: '差異展開方式',
|
||||
changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡',
|
||||
changesDiffOpenFloat: '浮動視窗',
|
||||
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
|
||||
changesDiffOpenPane: '面板',
|
||||
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
|
||||
changesLoadError: '工作階段檔案記錄暫不可用',
|
||||
staged: '已暫存',
|
||||
unstaged: '未暫存',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -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<keyof typeof zh, string> = {
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const PrefsSchema: z<SidebarPrefs> = 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(''),
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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'])
|
||||
|
||||
+27
-12
@@ -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<string, unknown> }> {
|
||||
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<HTMLButtonElement>('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 {
|
||||
|
||||
@@ -97,6 +97,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ 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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+8
-8
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user