dsh-git-ui 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 变更文件路径 → 可折叠目录树(IDEA 右栏文件树形态)。
3
+ * 输入为 show 查询的 name-status 行(路径 + 变更状态),输出嵌套节点:
4
+ * 目录在前、文件在后,同层按名称字母序;文件节点携带状态(右栏按状态着色)。
5
+ * 不依赖 React,可纯单元测试。
6
+ */
7
+
8
+ /** 一个变更行(与宿主 GitFileStat 同构)。 */
9
+ export interface FileStatLike {
10
+ readonly path: string
11
+ readonly status: string
12
+ }
13
+
14
+ /** 目录树节点。 */
15
+ export interface FileTreeNode {
16
+ /** 当前段名称(目录名或文件名)。 */
17
+ readonly name: string
18
+ /** 完整路径(目录 = 前缀路径,文件 = 文件路径)。 */
19
+ readonly path: string
20
+ readonly dir: boolean
21
+ /** 目录节点的后代;文件节点恒为空。 */
22
+ readonly children: readonly FileTreeNode[]
23
+ /** 文件节点的变更状态;目录节点为 undefined。 */
24
+ readonly status?: string
25
+ }
26
+
27
+ interface MutableNode {
28
+ name: string
29
+ path: string
30
+ dir: boolean
31
+ children: Map<string, MutableNode>
32
+ status?: string
33
+ }
34
+
35
+ function newDirNode(name: string, path: string): MutableNode {
36
+ return { name, path, dir: true, children: new Map() }
37
+ }
38
+
39
+ /**
40
+ * 变更路径 → 展示段拆分。目录条目以 `dir/`(尾斜杠)出现:目录必须剥掉尾
41
+ * 斜杠后取末段为名、前缀为空,否则会把目录误当文件(`.agent/` 用裸
42
+ * lastIndexOf('/') 会得到空名 + `.agent` 目录段)。
43
+ * `isDir` 透传 host 解析的权威目录标记(GitChange.isDirectory);缺省时
44
+ * 回退字符串派生态(diff 面包屑等仅有 path 的场景)。
45
+ */
46
+ export function splitChangePath(path: string, isDir?: boolean): { name: string; dir: string; isDir: boolean } {
47
+ const dirEntry = isDir ?? path.endsWith('/')
48
+ // 统一剥掉尾部斜杠:目录条目去掉以示目录名;非目录(权威 isDir=false)
49
+ // 残留的规范化尾斜杠也一并清理,避免末段拆出空名。
50
+ const display = path.replace(/\/+$/, '')
51
+ const slash = display.lastIndexOf('/')
52
+ return slash === -1
53
+ ? { name: display, dir: '', isDir: dirEntry }
54
+ : { name: display.slice(slash + 1), dir: display.slice(0, slash), isDir: dirEntry }
55
+ }
56
+
57
+ /** 由变更行列表构建目录树根节点集合。 */
58
+ export function buildFileTree(stats: readonly FileStatLike[]): readonly FileTreeNode[] {
59
+ const root: MutableNode = newDirNode('', '')
60
+ for (const stat of stats) {
61
+ // 尾斜杠 = 目录条目(git status 未跟踪目录形态):末段按目录节点处理。
62
+ const isDir = stat.path.endsWith('/')
63
+ const segments = stat.path.split('/').filter((s) => s !== '')
64
+ if (segments.length === 0) continue
65
+ let cursor = root
66
+ let prefix = ''
67
+ for (let i = 0; i < segments.length; i += 1) {
68
+ const segment = segments[i]!
69
+ prefix = prefix === '' ? segment : `${prefix}/${segment}`
70
+ const isLast = i === segments.length - 1
71
+ let next = cursor.children.get(segment)
72
+ if (next === undefined) {
73
+ next = isLast
74
+ ? { name: segment, path: prefix, dir: isDir, children: new Map(), ...(isDir ? {} : { status: stat.status }) }
75
+ : newDirNode(segment, prefix)
76
+ cursor.children.set(segment, next)
77
+ }
78
+ cursor = next
79
+ }
80
+ }
81
+ return freeze(root.children)
82
+ }
83
+
84
+ /** 递归转换并排序:目录在前、文件在后,同层字母序。 */
85
+ function freeze(level: Map<string, MutableNode>): readonly FileTreeNode[] {
86
+ const nodes: FileTreeNode[] = []
87
+ for (const node of level.values()) {
88
+ nodes.push({
89
+ name: node.name,
90
+ path: node.path,
91
+ dir: node.dir,
92
+ children: node.dir ? freeze(node.children) : [],
93
+ ...(node.status === undefined ? {} : { status: node.status }),
94
+ })
95
+ }
96
+ nodes.sort((a, b) => {
97
+ if (a.dir !== b.dir) return a.dir ? -1 : 1
98
+ return a.name.localeCompare(b.name)
99
+ })
100
+ return nodes
101
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Git 分支图数据模型与车道生命周期列分配算法。
3
+ *
4
+ * 输入为 `git log --all` 拓扑序(新→旧)的 `GraphCommit[]`。本模块维护一组
5
+ * “车道”(lane):每条车道等待下一个应出现的提交(waiting-for)。提交到达时:
6
+ * - 等待该提交的所有车道中,首个承载节点,其余(`joins`)在本行经水平连接线
7
+ * 汇入节点——**多子汇向同一父的汇聚锚定在父节点行**(IDEA 式分叉/汇聚,
8
+ * 不再提前回归,修复原「return 曲线在父节点上方一行拐弯」的视觉偏移);
9
+ * - 首父继承当前车道(直线延续);
10
+ * - 第二及更多父提交复用已等待它的车道,否则开辟新车道,均产生分裂曲线;
11
+ * - 释放的车道按索引回收,列数贴近历史真实并发度,永不单调增长。
12
+ *
13
+ * 一个哈希可同时被多条车道等待(分叉的多个子提交各占一条);该提交出现时
14
+ * 全部收敛:节点落在首条车道,其余车道画「竖线→水平连接→节点」。
15
+ *
16
+ * 渲染契约(与行样式共用单一事实来源):行高固定、条带占满整行,
17
+ * 竖线在行间自然衔接;分裂用贝塞尔曲线,汇聚用水平连接线。
18
+ *
19
+ * 不依赖 React,可纯单元测试。
20
+ */
21
+ import type { GraphCommit } from '../host/types.ts'
22
+
23
+ /** 渲染一行所需的全部几何信息(对应一个提交)。 */
24
+ export interface GraphRow {
25
+ readonly commit: GraphCommit
26
+ /** 节点所在的 0 基列号。 */
27
+ readonly column: number
28
+ /** 贯穿整行(0→行高)的竖线车道:处理本行前后均活跃的车道。 */
29
+ readonly verticals: readonly number[]
30
+ /** 自上方来、本行汇入节点后关闭的车道:画竖线到节点高度 + 水平连接线入节点。 */
31
+ readonly joins: readonly number[]
32
+ /** 节点车道在本行之前已存在:自行顶到节点画半段竖线。 */
33
+ readonly nodeFromTop: boolean
34
+ /** 节点车道在本行之后延续:自节点到行底画半段竖线。 */
35
+ readonly nodeContinues: boolean
36
+ /** 自节点向下的分裂曲线(merge 的第二父 fan-out),终点在行底的目标车道。 */
37
+ readonly edges: readonly GraphEdge[]
38
+ }
39
+
40
+ /** 一条分裂曲线(merge 节点 → 第二父车道,着目标车道色)。 */
41
+ export interface GraphEdge {
42
+ readonly from: number
43
+ readonly to: number
44
+ }
45
+
46
+ /**
47
+ * 车道列的语义调色板(明暗主题下均有足够对比度),16 色循环。
48
+ * 分支身份色与主题解耦是有意为之(与 IDEA 一致的固定车道配色)。
49
+ */
50
+ export const GRAPH_COLORS: readonly string[] = [
51
+ '#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899',
52
+ '#06B6D4', '#84CC16', '#F97316', '#6366F1', '#14B8A6', '#E11D48',
53
+ '#0EA5E9', '#A855F7', '#22C55E', '#F43F5E',
54
+ ]
55
+
56
+ /** 回收第一个空闲车道索引;无空闲则扩容。 */
57
+ function freeLane(lanes: (string | null)[], opened: Set<number>): number {
58
+ const index = lanes.indexOf(null)
59
+ const lane = index === -1 ? (lanes.push(null), lanes.length - 1) : index
60
+ opened.add(lane)
61
+ return lane
62
+ }
63
+
64
+ /** 处理单个提交:更新车道并产出该行几何(buildGraph 与增量 builder 共用同一循环体)。 */
65
+ function processCommit(commit: GraphCommit, lanes: (string | null)[]): GraphRow {
66
+ const opened = new Set<number>()
67
+ // 1. 节点列:等待该提交的所有车道(分叉多子各占一条)中首个;否则新开车道(分支尖端)。
68
+ // 其余等待车道记入 `joins`——本行经「竖线→水平连接线→节点」汇入(锚定父节点行)。
69
+ const waiting: number[] = []
70
+ lanes.forEach((waitingHash, index) => {
71
+ if (waitingHash === commit.hash) waiting.push(index)
72
+ })
73
+ const column = waiting.length > 0 ? waiting[0]! : freeLane(lanes, opened)
74
+ const nodeFromTop = waiting.length > 0
75
+ const joins = waiting.slice(1)
76
+ for (const index of waiting) lanes[index] = null
77
+
78
+ // 2. 首父:本车道直线延续(新模型不提前回归——汇聚由父节点行的 joins 表达)。
79
+ const edges: GraphEdge[] = []
80
+ const firstParent = commit.parents[0]
81
+ let nodeContinues = false
82
+ if (firstParent !== undefined) {
83
+ lanes[column] = firstParent
84
+ nodeContinues = true
85
+ }
86
+
87
+ // 3. 第二及更多父提交:复用已等待它的车道,否则开辟新车道;均记分裂曲线。
88
+ for (let p = 1; p < commit.parents.length; p += 1) {
89
+ const parentHash = commit.parents[p]!
90
+ let target = lanes.indexOf(parentHash)
91
+ if (target === -1) {
92
+ target = freeLane(lanes, opened)
93
+ lanes[target] = parentHash
94
+ }
95
+ edges.push({ from: column, to: target })
96
+ }
97
+
98
+ // 4. 贯穿竖线:处理后仍在等待的车道;节点列、本行新开、本行汇入(joins)的车道除外。
99
+ const excluded = new Set<number>(joins)
100
+ excluded.add(column)
101
+ const verticals: number[] = []
102
+ lanes.forEach((waitingHash, index) => {
103
+ if (waitingHash === null || excluded.has(index) || opened.has(index)) return
104
+ verticals.push(index)
105
+ })
106
+
107
+ return { commit, column, verticals, joins, nodeFromTop, nodeContinues, edges }
108
+ }
109
+
110
+ /**
111
+ * 增量图构建器:持有车道末态,`append` 只处理新增提交并返回新增行。
112
+ * 既有行对象保持同一引用——配合 CommitRow 的 React.memo,避免逐批追加时全表重渲染,
113
+ * 也免除每次滚动对全部已加载提交的 O(n·车道) 全量重算。
114
+ * 集合被整体替换(过滤切换/缓存恢复)时请新建 builder 并从零 `append`。
115
+ */
116
+ export interface GraphBuilder {
117
+ /** 已处理提交总数。 */
118
+ readonly count: number
119
+ /** 追加一批新提交(不得重复传入已处理过的提交),返回这批新增的行。 */
120
+ append(commits: readonly GraphCommit[]): readonly GraphRow[]
121
+ }
122
+
123
+ export function createGraphBuilder(): GraphBuilder {
124
+ const lanes: (string | null)[] = []
125
+ let count = 0
126
+ return {
127
+ get count() {
128
+ return count
129
+ },
130
+ append(commits) {
131
+ const rows: GraphRow[] = []
132
+ for (const commit of commits) {
133
+ rows.push(processCommit(commit, lanes))
134
+ count += 1
135
+ }
136
+ return rows
137
+ },
138
+ }
139
+ }
140
+
141
+ /** 一次性图构建:等价于 `createGraphBuilder().append(commits)`。 */
142
+ export function buildGraph(commits: readonly GraphCommit[]): readonly GraphRow[] {
143
+ return createGraphBuilder().append(commits)
144
+ }
145
+
146
+ /** 图宽度(车道数):所有行涉及的最大列号 + 1;空图为 0。循环求值,避免大数组展开。 */
147
+ export function graphWidth(rows: readonly GraphRow[]): number {
148
+ let width = 0
149
+ const consider = (col: number): void => {
150
+ if (col + 1 > width) width = col + 1
151
+ }
152
+ for (const row of rows) {
153
+ consider(row.column)
154
+ for (const col of row.verticals) consider(col)
155
+ for (const join of row.joins) consider(join)
156
+ for (const edge of row.edges) consider(edge.to)
157
+ }
158
+ return width
159
+ }
160
+
161
+ /** 带悬垂标记的行:`endOpen` 表示延续线指向的父提交不在已加载集合(图上会悬垂)。 */
162
+ export interface GraphRowMarker extends GraphRow {
163
+ readonly endOpen?: boolean
164
+ }
165
+
166
+ /**
167
+ * 标记「延续线指向的父提交不在已加载集合」的行——图上的悬垂竖线。
168
+ *
169
+ * 过滤(搜索/作者/日期)下结果集不含某些提交的父节点,`buildGraph` 的车道
170
+ * 永远等不到父,`nodeContinues` 的延续线会永久悬垂;分页边界同理但随下页
171
+ * 加载消散。`filtered` 为 false 时不标记,让边界悬垂随下页自愈。
172
+ * 纯函数,可单测。
173
+ */
174
+ export function markFilterEnds(
175
+ rows: readonly GraphRow[],
176
+ loadedHashes: ReadonlySet<string>,
177
+ filtered: boolean,
178
+ ): readonly GraphRowMarker[] {
179
+ if (!filtered) return rows as readonly GraphRowMarker[]
180
+ return rows.map((row): GraphRowMarker => {
181
+ if (!row.nodeContinues) return row
182
+ const parent = row.commit.parents[0]
183
+ if (parent !== undefined && !loadedHashes.has(parent)) {
184
+ return { ...row, endOpen: true }
185
+ }
186
+ return row
187
+ })
188
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * 通用内联 SVG 图标(纯组件,currentColor 着色,明暗主题自适应)。
3
+ * 取代文本符号/emoji(☁ ▸ 等),保证跨平台渲染一致且跟随语义令牌。
4
+ */
5
+ import type { JSX } from 'react'
6
+
7
+ /** 折叠/展开箭头:open 时旋转 90°,带平台快速过渡。 */
8
+ export function ChevronIcon({ open }: { readonly open: boolean }): JSX.Element {
9
+ return (
10
+ <svg
11
+ width={10}
12
+ height={10}
13
+ viewBox="0 0 10 10"
14
+ style={{
15
+ display: 'block',
16
+ flex: 'none',
17
+ transform: open ? 'rotate(90deg)' : 'none',
18
+ transition: 'transform var(--ds-transition-duration-fast) var(--ds-ease-in-out)',
19
+ }}
20
+ aria-hidden="true"
21
+ >
22
+ <path
23
+ d="M3 1.5 L7 5 L3 8.5"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth={1.5}
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ />
30
+ </svg>
31
+ )
32
+ }
33
+
34
+ /** 目录图标(实心文件夹)。 */
35
+ export function FolderIcon(): JSX.Element {
36
+ return (
37
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
38
+ <path
39
+ d="M1 3 a1 1 0 0 1 1-1 h2.6 l1.2 1.4 H10 a1 1 0 0 1 1 1 V9 a1 1 0 0 1-1 1 H2 a1 1 0 0 1-1-1 Z"
40
+ fill="currentColor"
41
+ opacity={0.75}
42
+ />
43
+ </svg>
44
+ )
45
+ }
46
+
47
+ /** 文件图标(折角文档轮廓)。 */
48
+ export function FileIcon(): JSX.Element {
49
+ return (
50
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
51
+ <path
52
+ d="M3 1 h4 l2.5 2.5 V11 h-6.5 Z M7 1 v2.5 h2.5"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ strokeWidth={1.2}
56
+ strokeLinejoin="round"
57
+ />
58
+ </svg>
59
+ )
60
+ }
61
+
62
+ // ── 文件类型分类图标(同一折角轮廓 + 内部符号区分)──────────────────────
63
+
64
+ /** 代码文件图标({ } 括号符号)。 */
65
+ export function CodeFileIcon(): JSX.Element {
66
+ return (
67
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
68
+ <path d="M3 1 h4 l2.5 2.5 V11 h-6.5 Z M7 1 v2.5 h2.5" fill="none" stroke="currentColor" strokeWidth={1.2} strokeLinejoin="round" />
69
+ <path d="M5 5.5 L4.2 7 L5 8.5 M7 5.5 L7.8 7 L7 8.5" fill="none" stroke="currentColor" strokeWidth={1} strokeLinecap="round" strokeLinejoin="round" />
70
+ </svg>
71
+ )
72
+ }
73
+
74
+ /** 配置文件图标(齿轮符号)。 */
75
+ export function ConfigFileIcon(): JSX.Element {
76
+ return (
77
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
78
+ <path d="M3 1 h4 l2.5 2.5 V11 h-6.5 Z M7 1 v2.5 h2.5" fill="none" stroke="currentColor" strokeWidth={1.2} strokeLinejoin="round" />
79
+ <circle cx={6} cy={7} r={1.4} fill="none" stroke="currentColor" strokeWidth={1} />
80
+ <path d="M6 5 V5.6 M6 8.4 V9 M4.6 7 H5.2 M6.8 7 H7.4" fill="none" stroke="currentColor" strokeWidth={1} strokeLinecap="round" />
81
+ </svg>
82
+ )
83
+ }
84
+
85
+ /** 文档文件图标(文本行符号)。 */
86
+ export function DocFileIcon(): JSX.Element {
87
+ return (
88
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
89
+ <path d="M3 1 h4 l2.5 2.5 V11 h-6.5 Z M7 1 v2.5 h2.5" fill="none" stroke="currentColor" strokeWidth={1.2} strokeLinejoin="round" />
90
+ <path d="M4.5 5 H7.5 M4.5 6.5 H7.5 M4.5 8 H6.5" fill="none" stroke="currentColor" strokeWidth={1} strokeLinecap="round" />
91
+ </svg>
92
+ )
93
+ }
94
+
95
+ /** 图片文件图标(山峰符号)。 */
96
+ export function ImageFileIcon(): JSX.Element {
97
+ return (
98
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
99
+ <path d="M3 1 h4 l2.5 2.5 V11 h-6.5 Z M7 1 v2.5 h2.5" fill="none" stroke="currentColor" strokeWidth={1.2} strokeLinejoin="round" />
100
+ <path d="M4 8.5 L5.5 6.5 L7 8.5 M6.5 8.5 L7.5 7 L8.5 8.5" fill="none" stroke="currentColor" strokeWidth={1} strokeLinecap="round" strokeLinejoin="round" />
101
+ </svg>
102
+ )
103
+ }
104
+
105
+ // ── 按扩展名/基名选择文件类型图标 ──────────────────────────────────────
106
+
107
+ const CODE_EXTS = new Set([
108
+ 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'rb', 'java', 'kt', 'kts',
109
+ 'swift', 'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'cs', 'vb', 'php', 'vue', 'svelte',
110
+ 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'lua', 'r', 'scala', 'clj', 'ex', 'exs',
111
+ 'erl', 'elm', 'dart', 'groovy', 'gradle', 'ml', 'fs', 'fsx', 'pl', 'pm', 'tcl',
112
+ ])
113
+ const CONFIG_EXTS = new Set([
114
+ 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'lock', 'properties',
115
+ 'editorconfig', 'gitignore', 'gitattributes', 'dockerignore', 'npmrc', 'prettierrc',
116
+ 'eslintrc', 'babelrc', 'node-version', 'nvmrc',
117
+ ])
118
+ const DOC_EXTS = new Set([
119
+ 'md', 'mdx', 'txt', 'rst', 'pdf', 'doc', 'docx', 'rtf', 'adoc', 'org',
120
+ ])
121
+ const IMAGE_EXTS = new Set([
122
+ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif',
123
+ ])
124
+ const STYLESHEET_EXTS = new Set([
125
+ 'css', 'scss', 'sass', 'less', 'styl',
126
+ ])
127
+
128
+ /** 按文件路径选择类型图标(扩展名/基名分类),不匹配时回退通用文件图标。
129
+ * 目录条目(尾斜杠,如 `.agent/`)先剥尾斜杠再分类。纯点文件(`.agent` 的
130
+ * 点即首字符)无扩展名语义:不把 `agent` 当后缀匹配代码/文档/图片等集合,
131
+ * 仅白名单配置类 dotfile(`.gitignore`/`.npmrc` 等)给配置图标,其余通用文件图标。 */
132
+ export function fileIconForPath(path: string): JSX.Element {
133
+ const clean = path.endsWith('/') ? path.slice(0, -1) : path
134
+ const baseName = clean.slice(clean.lastIndexOf('/') + 1).toUpperCase()
135
+ if (baseName === 'LICENSE' || baseName === 'README' || baseName === 'CHANGELOG' || baseName === 'AUTHORS') return <DocFileIcon />
136
+ if (baseName === 'MAKEFILE' || baseName === 'DOCKERFILE' || baseName === 'CMAKECACHE') return <CodeFileIcon />
137
+ const dot = clean.lastIndexOf('.')
138
+ if (dot === -1) return <FileIcon />
139
+ const ext = clean.slice(dot + 1).toLowerCase()
140
+ // 纯点文件:点即首字符(如 `.agent`)——不把首段当扩展名参与类型分类。
141
+ if (dot === 0) return CONFIG_EXTS.has(ext) ? <ConfigFileIcon /> : <FileIcon />
142
+ if (CODE_EXTS.has(ext)) return <CodeFileIcon />
143
+ if (CONFIG_EXTS.has(ext)) return <ConfigFileIcon />
144
+ if (DOC_EXTS.has(ext)) return <DocFileIcon />
145
+ if (IMAGE_EXTS.has(ext)) return <ImageFileIcon />
146
+ if (STYLESHEET_EXTS.has(ext)) return <CodeFileIcon />
147
+ return <FileIcon />
148
+ }
149
+
150
+ /** 分支图标(git branch)。 */
151
+ export function BranchIcon(): JSX.Element {
152
+ return (
153
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
154
+ <circle cx={3.5} cy={2.5} r={1.5} fill="currentColor" />
155
+ <circle cx={3.5} cy={9.5} r={1.5} fill="currentColor" />
156
+ <circle cx={8.5} cy={2.5} r={1.5} fill="currentColor" />
157
+ <path d="M3.5 4 v4 M8.5 4 c0 2.5-2.5 2.5-4.5 3" fill="none" stroke="currentColor" strokeWidth={1.2} />
158
+ </svg>
159
+ )
160
+ }
161
+
162
+
163
+ /** 星形图标(默认/main 分支标识,IDEA 式)。 */
164
+ export function StarIcon(): JSX.Element {
165
+ return (
166
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
167
+ <path
168
+ d="M6 0.8 l1.5 3.1 3.4 0.45 -2.5 2.35 0.65 3.35 L6 8.45 l-3.05 1.6 0.65-3.35 -2.5-2.35 3.4-0.45 Z"
169
+ fill="currentColor"
170
+ />
171
+ </svg>
172
+ )
173
+ }
174
+
175
+ /** 全部展开(双箭头下)。 */
176
+ export function ExpandAllIcon(): JSX.Element {
177
+ return (
178
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block' }} aria-hidden="true">
179
+ <path d="M2 1.5 L6 5 L10 1.5 M2 6 L6 9.5 L10 6" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
180
+ </svg>
181
+ )
182
+ }
183
+
184
+ /** 全部收起(双箭头上)。 */
185
+ export function CollapseAllIcon(): JSX.Element {
186
+ return (
187
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block' }} aria-hidden="true">
188
+ <path d="M2 4.5 L6 1 L10 4.5 M2 9 L6 5.5 L10 9" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
189
+ </svg>
190
+ )
191
+ }
192
+
193
+ /** 标签图标。 */
194
+ export function TagIcon(): JSX.Element {
195
+ return (
196
+ <svg width={12} height={12} viewBox="0 0 12 12" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
197
+ <path
198
+ d="M1.5 1.5 h4 l5 5 l-4 4 l-5-5 Z"
199
+ fill="none"
200
+ stroke="currentColor"
201
+ strokeWidth={1.2}
202
+ strokeLinejoin="round"
203
+ />
204
+ <circle cx={3.8} cy={3.8} r={0.9} fill="currentColor" />
205
+ </svg>
206
+ )
207
+ }
208
+
209
+ // ── Changes 行悬停操作图标(IDEA 式:对照/暂存/取消暂存/回滚)────────────
210
+
211
+ /** 对照查看图标:并排双栏窗格。 */
212
+ export function DiffIcon(): JSX.Element {
213
+ return (
214
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
215
+ <rect x={1.5} y={2.5} width={11} height={9} rx={1.2} fill="none" stroke="currentColor" strokeWidth={1.2} />
216
+ <path d="M7 2.5 v9" stroke="currentColor" strokeWidth={1.2} />
217
+ </svg>
218
+ )
219
+ }
220
+
221
+ /** 暂存图标(加号:加入暂存区)。 */
222
+ export function StageIcon(): JSX.Element {
223
+ return (
224
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
225
+ <path d="M7 3 v8 M3 7 h8" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" />
226
+ </svg>
227
+ )
228
+ }
229
+
230
+ /** 取消暂存图标(减号:移出暂存区)。 */
231
+ export function UnstageIcon(): JSX.Element {
232
+ return (
233
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
234
+ <path d="M3 7 h8" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" />
235
+ </svg>
236
+ )
237
+ }
238
+
239
+ /** 回滚(丢弃)图标:撤销箭头。 */
240
+ export function RollbackIcon(): JSX.Element {
241
+ return (
242
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
243
+ <path
244
+ d="M5.5 3 L3 5.5 L5.5 8 M3.4 5.5 h5.1 a3 3 0 0 1 0 6 h-2"
245
+ fill="none"
246
+ stroke="currentColor"
247
+ strokeWidth={1.3}
248
+ strokeLinecap="round"
249
+ strokeLinejoin="round"
250
+ />
251
+ </svg>
252
+ )
253
+ }
254
+
255
+ /** 上一个(左箭头)。 */
256
+ export function PrevIcon(): JSX.Element {
257
+ return (
258
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
259
+ <path d="M8.5 3 L4.5 7 L8.5 11" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
260
+ </svg>
261
+ )
262
+ }
263
+
264
+ /** 下一个(右箭头)。 */
265
+ export function NextIcon(): JSX.Element {
266
+ return (
267
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
268
+ <path d="M5.5 3 L9.5 7 L5.5 11" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
269
+ </svg>
270
+ )
271
+ }
272
+
273
+ /** 关闭(叉号)。 */
274
+ export function CloseIcon(): JSX.Element {
275
+ return (
276
+ <svg width={14} height={14} viewBox="0 0 14 14" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
277
+ <path d="M3.5 3.5 L10.5 10.5 M10.5 3.5 L3.5 10.5" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" />
278
+ </svg>
279
+ )
280
+ }
281
+
282
+ /** 告警三角(圆角填充 + 感叹号),用于告警横幅语义图标。 */
283
+ export function AlertIcon(): JSX.Element {
284
+ return (
285
+ <svg width={16} height={16} viewBox="0 0 16 16" style={{ display: 'block', flex: 'none' }} aria-hidden="true">
286
+ <path d="M8 1.5 L14.5 13 L1.5 13 Z" fill="currentColor" opacity={0.15} stroke="currentColor" strokeWidth={1.2} strokeLinejoin="round" />
287
+ <path d="M8 6 V9.5" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" />
288
+ <circle cx={8} cy={11.3} r={0.8} fill="currentColor" />
289
+ </svg>
290
+ )
291
+ }
292
+
@@ -19,7 +19,7 @@
19
19
  * standalone plugin mounts its own.)
20
20
  */
21
21
  import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
22
- import { GitController, type GitView, type GitObservable, type GitRemoteLike } from './controller.ts'
22
+ import { GitController, type GitRemoteLike } from './controller.ts'
23
23
  import { gitInfoRemote } from './remote.ts'
24
24
  import { GitPill, type GitInjected } from './GitPill.tsx'
25
25
  import { en, zh } from './locales.ts'
@@ -125,6 +125,7 @@ export async function apply(ctx: ClientContext): Promise<void> {
125
125
  hooks: { git: controller as GitInjected['hooks']['git'] },
126
126
  refresh: () => controller.refresh(),
127
127
  run: (action) => controller.run(action),
128
+ query: (query) => controller.query(query),
128
129
  }
129
130
  faces.set(sessionId, face)
130
131
  }