From d68603ee0696c0a4051273eaccf65590ed26aeb4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 25 Jul 2026 11:08:48 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=20tab=20=E9=BB=98=E8=AE=A4=E7=BB=9F=E4=B8=80=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=B9=B6=E4=BF=9D=E7=95=99=E9=98=85=E8=AF=BB=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/cc/prDiffMode.test.ts | 51 +++++++++++++++++++ vscode/src/cc/prDiffMode.ts | 20 ++++++++ vscode/src/panel/KanbanPanel.ts | 8 +++ vscode/src/panel/handlers/prFiles.ts | 20 ++++++++ vscode/src/panel/messages.ts | 3 ++ .../webview-ui/src/components/BottomTabs.tsx | 43 +++++++++++----- .../src/components/PrFilesPanel.tsx | 34 ++++++++++++- .../src/components/prFiles/FileDiffPane.tsx | 51 ++++++++++++++++--- vscode/webview-ui/src/lib/messages.ts | 3 ++ 9 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 vscode/src/cc/prDiffMode.test.ts create mode 100644 vscode/src/cc/prDiffMode.ts diff --git a/vscode/src/cc/prDiffMode.test.ts b/vscode/src/cc/prDiffMode.test.ts new file mode 100644 index 0000000..96efc1c --- /dev/null +++ b/vscode/src/cc/prDiffMode.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PR_DIFF_MODE, + isPrDiffMode, + parsePrDiffMode, + type PrDiffMode, +} from './prDiffMode' + +describe('DEFAULT_PR_DIFF_MODE', () => { + it('默认是 unified', () => { + expect(DEFAULT_PR_DIFF_MODE).toBe('unified') + }) +}) + +describe('isPrDiffMode', () => { + it('仅接受 unified / split', () => { + expect(isPrDiffMode('unified')).toBe(true) + expect(isPrDiffMode('split')).toBe(true) + expect(isPrDiffMode('Unified')).toBe(false) + expect(isPrDiffMode('SPLIT')).toBe(false) + expect(isPrDiffMode('')).toBe(false) + expect(isPrDiffMode(null)).toBe(false) + expect(isPrDiffMode(undefined)).toBe(false) + expect(isPrDiffMode(3)).toBe(false) + }) +}) + +describe('parsePrDiffMode', () => { + it('split → split', () => { + expect(parsePrDiffMode('split')).toBe('split') + }) + + it('unified → unified', () => { + expect(parsePrDiffMode('unified')).toBe('unified') + }) + + it('缺省 / 非法一律 unified', () => { + expect(parsePrDiffMode(undefined)).toBe('unified') + expect(parsePrDiffMode(null)).toBe('unified') + expect(parsePrDiffMode('')).toBe('unified') + expect(parsePrDiffMode('Split')).toBe('unified') + expect(parsePrDiffMode('UNIFIED')).toBe('unified') + expect(parsePrDiffMode(1)).toBe('unified') + expect(parsePrDiffMode({})).toBe('unified') + }) + + it('返回类型可赋值给 PrDiffMode', () => { + const mode: PrDiffMode = parsePrDiffMode('split') + expect(mode).toBe('split') + }) +}) diff --git a/vscode/src/cc/prDiffMode.ts b/vscode/src/cc/prDiffMode.ts new file mode 100644 index 0000000..41e4d58 --- /dev/null +++ b/vscode/src/cc/prDiffMode.ts @@ -0,0 +1,20 @@ +/** + * 「改动」tab 的 diff 模式(统一 / 分栏)解析。 + * 纯逻辑:不碰 VS Code API,便于单测。 + */ + +export const DEFAULT_PR_DIFF_MODE = 'unified' as const + +export type PrDiffMode = 'unified' | 'split' + +export function isPrDiffMode(x: unknown): x is PrDiffMode { + return x === 'unified' || x === 'split' +} + +/** + * workspaceState 读出值 → 合法模式。 + * 仅精确字符串 `'split'` 才是分栏;其它一律统一(默认路径)。 + */ +export function parsePrDiffMode(stored: unknown): PrDiffMode { + return stored === 'split' ? 'split' : DEFAULT_PR_DIFF_MODE +} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index b339098..2a0f62d 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -588,6 +588,14 @@ export class KanbanWebviewPanel { void prFiles.handleSetPrReviewConfirmed(this, msg) return } + if (msg.type === 'pr-diff-mode/get') { + prFiles.handleGetPrDiffMode(this) + return + } + if (msg.type === 'pr-diff-mode/set') { + void prFiles.handleSetPrDiffMode(this, msg.mode) + return + } } // internal: handler 模块访问 diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts index 5633f2e..2085646 100644 --- a/vscode/src/panel/handlers/prFiles.ts +++ b/vscode/src/panel/handlers/prFiles.ts @@ -19,6 +19,7 @@ import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/ap import { readStateJsonComment } from '../../gitea/stateJson' import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore' import { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore' +import { parsePrDiffMode, type PrDiffMode } from '../../cc/prDiffMode' import { makeNonce } from '../KanbanPanel' /** @@ -351,3 +352,22 @@ export async function handleGeneratePrFileSummary( }) } } + +const PR_DIFF_MODE_KEY = 'prDiffMode' + +/** 读 workspace 级 diff 模式(缺省 unified),回推 webview。 */ +export function handleGetPrDiffMode(panel: KanbanWebviewPanel): void { + const stored = panel.context.workspaceState.get(PR_DIFF_MODE_KEY) + const mode = parsePrDiffMode(stored) + panel.postMessage({ type: 'pr-diff-mode/show', mode }) +} + +/** 写 workspace 级 diff 模式;非法值按 unified 落盘。 */ +export async function handleSetPrDiffMode( + panel: KanbanWebviewPanel, + mode: unknown, +): Promise { + const next: PrDiffMode = parsePrDiffMode(mode) + await panel.context.workspaceState.update(PR_DIFF_MODE_KEY, next) +} + diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index fe84de4..38d1631 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -109,6 +109,7 @@ export type ExtensionToWebview | { type: 'pr-files/show', issueNumber: number, files: PrFile[], confirmed: string[], summaries: Record, error?: string } | { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string } | { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string } + | { type: 'pr-diff-mode/show', mode: 'unified' | 'split' } // profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。 | { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string } | { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string } @@ -194,6 +195,8 @@ export type WebviewToExtension | { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string } | { type: 'pr-review/set', issueNumber: number, confirmed: Record } + | { type: 'pr-diff-mode/get' } + | { type: 'pr-diff-mode/set', mode: 'unified' | 'split' } // profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。 | { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string } | { type: 'profile-asset/load', requestId: string, assetPath: string } diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 84c9b25..5a449ac 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -1,10 +1,13 @@ /** - * 底部 panel:左侧垂直 tab 切换两个 property grid。 + * 底部 panel:左侧垂直 tab 切换四个内容区。 * * - tab 1 「工单」:现有 IssueDetailPanel(基于 PropertyGrid) * - tab 2 「Profile」:工作区 KV 表 ProfileGrid + * - tab 3 「会话」:ManagedSessionsPanel + * - tab 4 「改动」:PrFilesPanel * - * tab 状态用 useState,无持久化;激活态加左侧 2px 高亮条。 + * 四个内容始终挂载,非当前用 hidden 隐藏,避免切 tab 卸载导致阅读态(选中文件、 + * 树滚动/折叠、diff scroll)丢失。tab 状态用 useState,无持久化;激活态加左侧 2px 高亮条。 */ import type { ClaudeProfile } from '../hooks/useIssues' @@ -99,9 +102,13 @@ export function BottomTabs(props: BottomTabsProps) { /> - {/* 右侧内容 */} -
- {tab === 'issue' && ( + {/* 右侧内容:始终挂载,非当前 hidden,保留阅读态 */} +
+
- )} - {tab === 'profile' && ( +
+
- )} - {tab === 'sessions' && ( +
+
- )} - {tab === 'changes' && ( +
+
- )} +
) diff --git a/vscode/webview-ui/src/components/PrFilesPanel.tsx b/vscode/webview-ui/src/components/PrFilesPanel.tsx index 144e672..4fb77c2 100644 --- a/vscode/webview-ui/src/components/PrFilesPanel.tsx +++ b/vscode/webview-ui/src/components/PrFilesPanel.tsx @@ -7,11 +7,14 @@ * * 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用 * merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按文件变更指纹落盘。 + * 统一/分栏模式按 workspace 持久化,与当前工单无关。 */ import type { Issue } from '../types' +import { DiffModeEnum } from '@git-diff-view/react' import { useEffect, useMemo, useState } from 'react' import { usePrFiles } from '../hooks/usePrFiles' +import { onMessage, postMessage } from '../lib/vscode' import { FileDiffPane } from './prFiles/FileDiffPane' import { FileTree } from './prFiles/FileTree' import { buildDirTree, flattenTree, isIgnoredPath } from './prFiles/fileTree' @@ -28,6 +31,14 @@ function Centered({ children }: { children: React.ReactNode }) { ) } +function modeToWire(mode: DiffModeEnum): 'unified' | 'split' { + return mode === DiffModeEnum.Split ? 'split' : 'unified' +} + +function modeFromWire(mode: 'unified' | 'split'): DiffModeEnum { + return mode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified +} + export function PrFilesPanel({ issue }: PrFilesPanelProps) { const issueNumber = issue?.number const { @@ -46,8 +57,19 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) { const [selectedPath, setSelectedPath] = useState(undefined) // 已折叠目录的完整路径集合;不在集合内即展开(默认全展开)。 const [collapsed, setCollapsed] = useState>(new Set()) + // workspace 级 diff 模式:默认统一;mount 时从扩展侧拉一次。 + const [diffMode, setDiffMode] = useState(DiffModeEnum.Unified) - // 切工单时清掉选中态与折叠态(hook 已清缓存)。 + useEffect(() => { + const cleanup = onMessage((msg) => { + if (msg.type === 'pr-diff-mode/show') + setDiffMode(modeFromWire(msg.mode)) + }) + postMessage({ type: 'pr-diff-mode/get' }) + return cleanup + }, []) + + // 切工单时清掉选中态与折叠态(hook 已清缓存);FileDiffPane 用 key 重置 scroll map。 useEffect(() => { setSelectedPath(undefined) setCollapsed(new Set()) @@ -78,6 +100,11 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) { const selectedFile = selectedPath ? fileByPath.get(selectedPath) : undefined + const onModeChange = (next: DiffModeEnum) => { + setDiffMode(next) + postMessage({ type: 'pr-diff-mode/set', mode: modeToWire(next) }) + } + // 所有 hook 必须在任何 early return 之前调用完,否则工单有无 PR 之间切换时 // hook 数量变化会触发 React #300(表现为面板空白 / 渲染出错)。 if (!issue) @@ -147,9 +174,10 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) { - {/* 右栏:选中文件的内联 diff */} + {/* 右栏:选中文件的内联 diff;key=工单号 → 换工单清 scroll map */}
selectedPath && generateSummary(selectedPath, selectedFile?.previousFilename)} + mode={diffMode} + onModeChange={onModeChange} />
diff --git a/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx index e171054..3bdc9da 100644 --- a/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx +++ b/vscode/webview-ui/src/components/prFiles/FileDiffPane.tsx @@ -6,13 +6,14 @@ * - 主题跟随 VS Code(body 的 vscode-light/dark class),MutationObserver 监听切换。 * - 语法高亮用库自带(diffViewHighlight),色彩取库的 light/dark 主题——不会和 VS Code * 主题 100% 一致,是本方案已知且可接受的小瑕疵。 + * - 分栏/统一模式由父级持有(workspace 级);本组件只按 path 记 scrollTop,换文件可回位。 */ import type { FileDiff } from '../../hooks/usePrFiles' import { generateDiffFile } from '@git-diff-view/file' import { DiffModeEnum, DiffView } from '@git-diff-view/react' import { Check, Loader2, Sparkles } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { statusBadge, statusColorVar } from './fileTree' import '@git-diff-view/react/styles/diff-view.css' @@ -33,6 +34,10 @@ interface FileDiffPaneProps { generating: boolean /** 触发 deepseek 生成当前文件说明。 */ onGenerate: () => void + /** 统一 / 分栏;由父级从 workspaceState 拉并持久化。 */ + mode: DiffModeEnum + /** 用户切换模式。 */ + onModeChange: (mode: DiffModeEnum) => void } /** 读当前 VS Code 主题明暗:body 带 vscode-light / vscode-high-contrast-light 即浅色,否则深色。 */ @@ -53,9 +58,22 @@ function Centered({ children }: { children: React.ReactNode }) { ) } -export function FileDiffPane({ path, status, diff, confirmed, onToggleConfirm, summary, generating, onGenerate }: FileDiffPaneProps) { - const [mode, setMode] = useState(DiffModeEnum.Split) +export function FileDiffPane({ + path, + status, + diff, + confirmed, + onToggleConfirm, + summary, + generating, + onGenerate, + mode, + onModeChange, +}: FileDiffPaneProps) { const [theme, setTheme] = useState<'light' | 'dark'>(() => readThemeKind()) + const scrollRef = useRef(null) + // 按文件路径记 scrollTop;随组件生命周期(key=issueNumber)重置。 + const scrollByPath = useRef>({}) // 主题随 VS Code 实时切换。 useEffect(() => { @@ -88,6 +106,20 @@ export function FileDiffPane({ path, status, diff, confirmed, onToggleConfirm, s return file }, [path, diff, mode, theme]) + // path / 内容就绪后恢复该文件上次的滚动位置;DiffView 异步出高时再 rAF 补一次。 + useLayoutEffect(() => { + const el = scrollRef.current + if (!el || !path) + return + const top = scrollByPath.current[path] ?? 0 + el.scrollTop = top + const id = requestAnimationFrame(() => { + if (scrollRef.current) + scrollRef.current.scrollTop = top + }) + return () => cancelAnimationFrame(id) + }, [path, diffFile]) + if (!path) return 选择左侧文件查看改动 @@ -141,7 +173,7 @@ export function FileDiffPane({ path, status, diff, confirmed, onToggleConfirm, s