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:
+61
-4
@@ -11,14 +11,21 @@
|
||||
*/
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent'
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff'
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
|
||||
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
|
||||
export type SidebarDiffRef =
|
||||
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
|
||||
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
|
||||
* `diff` carries the change a diff tab shows. */
|
||||
export interface SidebarTab {
|
||||
id: string
|
||||
type: TabType
|
||||
title: string
|
||||
path?: string
|
||||
diff?: SidebarDiffRef
|
||||
}
|
||||
|
||||
/** A tab group. */
|
||||
@@ -320,6 +327,13 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'diff') {
|
||||
// One tab per change: the same path+side (or commit) focuses its instance.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePane: targetId,
|
||||
@@ -358,6 +372,38 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
|
||||
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a diff tab the VSCode way: an existing instance of the same change is
|
||||
* focused wherever it lives; otherwise the tab joins the first pane that
|
||||
* already holds diff tabs (diff panes are sticky — repeated clicks stack
|
||||
* there); on the FIRST diff of a layout the source pane splits vertically so
|
||||
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
|
||||
* @returns the new state, with the diff pane active.
|
||||
*/
|
||||
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
|
||||
const existingLeaf = leafWithTab(state.splits, tab.id)
|
||||
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
|
||||
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
|
||||
if (diffLeaf !== undefined) {
|
||||
return {
|
||||
...state,
|
||||
activePane: diffLeaf.id,
|
||||
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
|
||||
leaf.tabs = [...leaf.tabs, tab]
|
||||
leaf.active = tab.id
|
||||
}),
|
||||
}
|
||||
}
|
||||
// First diff: split the source pane, the diff tab in the new LOWER leaf.
|
||||
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
|
||||
// regular open path instead of dropping the tab into an orphaned leaf.)
|
||||
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
|
||||
return openTab(state, tab)
|
||||
}
|
||||
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
|
||||
return { ...state, splits: result.node, activePane: result.leafId }
|
||||
}
|
||||
|
||||
/** Toggle the panel open/closed (opening restores the previous layout). */
|
||||
export function togglePanel(state: SidebarState): SidebarState {
|
||||
return { ...state, panelOpen: !state.panelOpen }
|
||||
@@ -563,10 +609,19 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
if (record.kind === 'leaf') {
|
||||
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
|
||||
const tabs: SidebarTab[] = []
|
||||
let droppedDiff = false
|
||||
for (const tab of record.tabs) {
|
||||
if (tab === null || typeof tab !== 'object') return undefined
|
||||
const candidate = tab as Record<string, unknown>
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
|
||||
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
|
||||
// they never survive a reload, so a stale ref (e.g. a wrong staged
|
||||
// side persisted by an older build) cannot resurface as a dead tab
|
||||
// showing "no text changes" after every refresh.
|
||||
if (candidate.type === 'diff') {
|
||||
droppedDiff = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
candidate.type !== 'explorer' && candidate.type !== 'git'
|
||||
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
|
||||
@@ -582,8 +637,10 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
})
|
||||
}
|
||||
const active = typeof record.active === 'string' ? record.active : null
|
||||
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
|
||||
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active }
|
||||
// An active pointer into a dropped diff tab is expected after the drop;
|
||||
// any other missing active is structural corruption → reset the state.
|
||||
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
|
||||
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
|
||||
}
|
||||
if (record.kind === 'split') {
|
||||
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
|
||||
|
||||
Reference in New Issue
Block a user