feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0
- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图 - 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复) - 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多) - 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗 - 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空) - 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用) - host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
This commit is contained in:
+161
-1
@@ -4,8 +4,10 @@
|
||||
* actual repository, and a real directory listing. Runs with `pnpm test`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
@@ -75,6 +77,31 @@ describe('host plugin smoke', () => {
|
||||
expect(branches.names).toContain(branches.current)
|
||||
})
|
||||
|
||||
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
|
||||
const cwd = process.cwd()
|
||||
const log = await git.log(cwd)
|
||||
const first = log[0]!
|
||||
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
|
||||
expect(typeof first.refs).toBe('string')
|
||||
const patch = await git.commitDiff(cwd, first.hashFull)
|
||||
expect(patch).toContain('diff --git')
|
||||
})
|
||||
|
||||
it('pages the log lazily with skip/count', async () => {
|
||||
const cwd = process.cwd()
|
||||
const first = await git.log(cwd, 5, 0)
|
||||
expect(first).toHaveLength(5)
|
||||
const second = await git.log(cwd, 5, 5)
|
||||
expect(second).toHaveLength(5)
|
||||
// The pages are disjoint windows over the same ordered history.
|
||||
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
|
||||
const all = await git.log(cwd, 10, 0)
|
||||
expect(all.slice(0, 5)).toEqual(first)
|
||||
expect(all.slice(5)).toEqual(second)
|
||||
// A skip past the end returns an empty page (the lazy loader's stop sign).
|
||||
expect(await git.log(cwd, 5, 10_000)).toEqual([])
|
||||
})
|
||||
|
||||
it('pty manager releases the quota on close and respawns after exit', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
@@ -165,6 +192,110 @@ describe('host plugin smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Destructive git operations (discard / revert / cherry-pick) run against a
|
||||
* throwaway repository under the OS temp dir — never the plugin repo. The
|
||||
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
|
||||
* environment variables, confined to the fixture process: no git config is
|
||||
* touched anywhere (the plugin never sets an identity, and neither does its
|
||||
* test fixture).
|
||||
*/
|
||||
describe('git destructive operations (scratch repository)', () => {
|
||||
const FIXTURE_IDENTITY = {
|
||||
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
|
||||
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
|
||||
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
|
||||
}
|
||||
|
||||
const gitRun = (cwd: string, args: string[]): string => {
|
||||
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...FIXTURE_IDENTITY },
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
/** A fresh repo on branch `main` with one committed file `a.txt`. */
|
||||
const makeScratchRepo = (): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
|
||||
gitRun(dir, ['init', '-q'])
|
||||
gitRun(dir, ['checkout', '-q', '-b', 'main'])
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'base'])
|
||||
return dir
|
||||
}
|
||||
|
||||
it('discard restores the worktree file from the index (staged changes kept)', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
// Unstaged-only changes: fully reverts to the committed content.
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
|
||||
await git.discard(dir, 'a.txt')
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
|
||||
// Staged changes: the worktree snaps back to the STAGED content and
|
||||
// the index is untouched (`git checkout -- <path>` restores from the
|
||||
// index — VSCode's "Discard Changes" semantics).
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
await git.discard(dir, 'a.txt')
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
|
||||
const staged = await git.diff(dir, 'a.txt', true)
|
||||
expect(staged).toContain('-two')
|
||||
expect(staged).toContain('+CHANGED')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('revert creates a revert commit', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'change'])
|
||||
const featureHash = (await git.log(dir))[0]!.hashFull
|
||||
await git.revert(dir, featureHash)
|
||||
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
|
||||
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cherry-pick applies a commit from another branch', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
|
||||
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
|
||||
gitRun(dir, ['add', '-A'])
|
||||
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
|
||||
const featureHash = (await git.log(dir))[0]!.hashFull
|
||||
gitRun(dir, ['checkout', '-q', 'main'])
|
||||
await git.cherryPick(dir, featureHash)
|
||||
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
|
||||
expect((await git.log(dir))[0]!.subject).toBe('feature work')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a failing destructive operation as a GitCommandError', async () => {
|
||||
const dir = makeScratchRepo()
|
||||
try {
|
||||
// An unknown revision fails before touching anything.
|
||||
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
|
||||
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session cwd resolution over the API route', () => {
|
||||
interface CtxOverrides {
|
||||
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
@@ -251,6 +382,35 @@ describe('session cwd resolution over the API route', () => {
|
||||
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
|
||||
expect(missing.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
|
||||
// The plugin repo's status paths are relative to the repo top level
|
||||
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
|
||||
// still load per-file diffs instead of failing with "not an absolute
|
||||
// path". The session header points INTO the repository.
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
|
||||
expect(result.ok).toBe(true)
|
||||
const value = result as unknown as { ok: boolean; value?: { diff: string } }
|
||||
expect(typeof value.value?.diff).toBe('string')
|
||||
})
|
||||
|
||||
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
|
||||
expect(result.ok).toBe(true)
|
||||
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
|
||||
expect(value.value?.kind).toBe('text')
|
||||
expect(value.value?.content).toContain('runGit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
|
||||
+222
-5
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
moveTab, moveTabToEdge, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SplitNode,
|
||||
moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
@@ -89,12 +90,125 @@ describe('git parsing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('parses log rows with unit separators', () => {
|
||||
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
|
||||
it('parses log rows with unit separators (full hash + refs)', () => {
|
||||
const rows = parseLogLines(
|
||||
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
|
||||
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
|
||||
{
|
||||
hash: 'abc1234',
|
||||
subject: 'First subject',
|
||||
author: 'Alice',
|
||||
date: '2024-01-01 10:00:00 +0800',
|
||||
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
|
||||
refs: 'HEAD -> main, origin/main',
|
||||
},
|
||||
{
|
||||
hash: 'def5678',
|
||||
subject: 'Second subject',
|
||||
author: 'Bob',
|
||||
date: '2024-01-02 10:00:00 +0800',
|
||||
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
|
||||
refs: '',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('parses a multi-file unified diff with aligned line numbers', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'index 1234567..89abcde 100644',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1,4 +1,5 @@ section with @@ inside',
|
||||
' line1',
|
||||
'-line2',
|
||||
'+line2b',
|
||||
' context',
|
||||
'+trailing',
|
||||
'diff --git a/README.md b/README.md',
|
||||
'new file mode 100644',
|
||||
'index 0000000..1234567',
|
||||
'--- /dev/null',
|
||||
'+++ b/README.md',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+hello',
|
||||
'+world',
|
||||
'',
|
||||
].join('\n')
|
||||
const parsed = parseUnifiedDiff(diff)
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
const first = parsed.files[0]!
|
||||
expect(first.oldPath).toBe('a/src/a.ts')
|
||||
expect(first.newPath).toBe('b/src/a.ts')
|
||||
expect(first.binary).toBe(false)
|
||||
expect(first.hunks).toHaveLength(1)
|
||||
expect(first.hunks[0]!.oldStart).toBe(1)
|
||||
expect(first.hunks[0]!.newStart).toBe(1)
|
||||
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
|
||||
expect(first.hunks[0]!.lines).toEqual([
|
||||
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
|
||||
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
|
||||
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
|
||||
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
|
||||
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
|
||||
])
|
||||
const second = parsed.files[1]!
|
||||
expect(second.oldPath).toBe('/dev/null')
|
||||
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
|
||||
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
|
||||
})
|
||||
|
||||
it('parses binary, deletion and no-newline markers', () => {
|
||||
const diff = [
|
||||
'diff --git a/img.png b/img.png',
|
||||
'index 111..222 100644',
|
||||
'Binary files a/img.png and b/img.png differ',
|
||||
'diff --git a/gone.ts b/gone.ts',
|
||||
'deleted file mode 100644',
|
||||
'--- a/gone.ts',
|
||||
'+++ /dev/null',
|
||||
'@@ -1,2 +0,0 @@',
|
||||
'-one',
|
||||
'-two',
|
||||
'\\ No newline at end of file',
|
||||
'',
|
||||
].join('\n')
|
||||
const parsed = parseUnifiedDiff(diff)
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
expect(parsed.files[0]!.binary).toBe(true)
|
||||
expect(parsed.files[0]!.hunks).toHaveLength(0)
|
||||
const gone = parsed.files[1]!
|
||||
expect(gone.newPath).toBe('/dev/null')
|
||||
expect(gone.hunks[0]!.lines).toEqual([
|
||||
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
|
||||
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
|
||||
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps mode/rename-only sections hunkless', () => {
|
||||
const parsed = parseUnifiedDiff([
|
||||
'diff --git a/run.sh b/run.sh',
|
||||
'old mode 100644',
|
||||
'new mode 100755',
|
||||
'diff --git a/old.ts b/new.ts',
|
||||
'similarity index 90%',
|
||||
'rename from old.ts',
|
||||
'rename to new.ts',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(parsed.files).toHaveLength(2)
|
||||
expect(parsed.files[0]!.oldPath).toBe('')
|
||||
expect(parsed.files[0]!.hunks).toHaveLength(0)
|
||||
expect(parsed.files[1]!.hunks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parses an empty or junk diff into no files', () => {
|
||||
expect(parseUnifiedDiff('').files).toEqual([])
|
||||
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar state', () => {
|
||||
@@ -119,6 +233,109 @@ describe('sidebar state', () => {
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
|
||||
})
|
||||
|
||||
const diffTab = (id: string): SidebarTab => ({
|
||||
id,
|
||||
type: 'diff',
|
||||
title: id,
|
||||
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
|
||||
})
|
||||
|
||||
it('first diff splits the source pane vertically (diff below)', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
expect(after.splits.kind).toBe('split')
|
||||
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
|
||||
expect(split.dir).toBe('col')
|
||||
expect(split.children).toHaveLength(2)
|
||||
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
|
||||
expect(split.children[0]!.id).toBe(sourcePane)
|
||||
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
|
||||
expect(after.activePane).toBe(split.children[1]!.id)
|
||||
})
|
||||
|
||||
it('reopening the same diff focuses its existing tab', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
// No new panes, no duplicate tabs.
|
||||
expect(second.splits.kind).toBe('split')
|
||||
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
|
||||
const allTabs = split.children.flatMap(child => child.tabs ?? [])
|
||||
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('subsequent diffs stack into the existing diff pane', () => {
|
||||
const s = state()
|
||||
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
|
||||
const withGit = openTab(s, gitTab)
|
||||
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
|
||||
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
|
||||
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
|
||||
// Still one split: the second diff joins the bottom leaf instead of splitting again.
|
||||
expect(second.splits.kind).toBe('split')
|
||||
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
|
||||
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
|
||||
expect(diffLeaves).toHaveLength(1)
|
||||
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
|
||||
'diff:w:u:src/a.ts',
|
||||
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
|
||||
])
|
||||
})
|
||||
|
||||
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
|
||||
const s = state()
|
||||
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
|
||||
expect(after.splits.kind).toBe('leaf')
|
||||
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
|
||||
})
|
||||
|
||||
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
|
||||
const valid = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'd1',
|
||||
tabs: [
|
||||
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
|
||||
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(valid?.splits.kind).toBe('leaf')
|
||||
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
|
||||
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
|
||||
// The dropped diff tab was the active one: the leaf falls back to a null
|
||||
// active instead of resetting the whole state.
|
||||
expect((valid?.splits as { active: string | null }).active).toBeNull()
|
||||
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
|
||||
const onlyDiff = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: 'd1',
|
||||
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
|
||||
},
|
||||
})
|
||||
expect(onlyDiff?.splits.kind).toBe('leaf')
|
||||
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
|
||||
Reference in New Issue
Block a user