|
1 | | -import type { JsonRenderElement, JsonRenderer, JsonRenderSpec, PluginWithDevTools } from '@vitejs/devtools-kit' |
2 | | -import { defineJsonRenderSpec, defineRpcFunction } from '@vitejs/devtools-kit' |
| 1 | +import type { JsonRenderer, PluginWithDevTools } from '@vitejs/devtools-kit' |
| 2 | +import { defineRpcFunction } from '@vitejs/devtools-kit' |
3 | 3 | import { exec } from 'tinyexec' |
4 | | - |
5 | | -interface GitResult { |
6 | | - stdout: string |
7 | | - stderr: string |
8 | | - ok: boolean |
9 | | -} |
10 | | - |
11 | | -async function git(args: string[], cwd: string): Promise<GitResult> { |
12 | | - try { |
13 | | - const result = await exec('git', args, { nodeOptions: { cwd }, throwOnError: true }) |
14 | | - return { stdout: result.stdout, stderr: result.stderr, ok: true } |
15 | | - } |
16 | | - catch (e: any) { |
17 | | - return { stdout: e.stdout ?? '', stderr: e.stderr ?? String(e), ok: false } |
18 | | - } |
19 | | -} |
20 | | - |
21 | | -interface GitState { |
22 | | - branch: string |
23 | | - commits: Array<{ hash: string, message: string, author: string, date: string }> |
24 | | - staged: Array<{ status: string, file: string }> |
25 | | - unstaged: Array<{ status: string, file: string }> |
26 | | -} |
27 | | - |
28 | | -async function getGitState(gitRoot: string): Promise<GitState> { |
29 | | - const [branchResult, logResult, statusResult] = await Promise.all([ |
30 | | - git(['branch', '--show-current'], gitRoot), |
31 | | - git(['log', '--oneline', '-20', '--format=%h\t%s\t%an\t%cr'], gitRoot), |
32 | | - git(['status', '--porcelain'], gitRoot), |
33 | | - ]) |
34 | | - const branch = branchResult.stdout |
35 | | - const log = logResult.stdout |
36 | | - const status = statusResult.stdout |
37 | | - |
38 | | - const staged: GitState['staged'] = [] |
39 | | - const unstaged: GitState['unstaged'] = [] |
40 | | - for (const line of status.split('\n').filter(Boolean)) { |
41 | | - const x = line[0] |
42 | | - const y = line[1] |
43 | | - const file = line.slice(3) |
44 | | - if (x !== ' ' && x !== '?') |
45 | | - staged.push({ status: x, file }) |
46 | | - if (y !== ' ' && y !== '?') |
47 | | - unstaged.push({ status: y, file }) |
48 | | - if (x === '?') |
49 | | - unstaged.push({ status: '?', file }) |
50 | | - } |
51 | | - |
52 | | - return { |
53 | | - branch: branch.trim(), |
54 | | - commits: log.split('\n').filter(Boolean).map((l) => { |
55 | | - const [hash, message, author, date] = l.split('\t') |
56 | | - return { hash, message, author, date } |
57 | | - }), |
58 | | - staged, |
59 | | - unstaged, |
60 | | - } |
61 | | -} |
62 | | - |
63 | | -function buildFileRows( |
64 | | - files: Array<{ status: string, file: string }>, |
65 | | - prefix: string, |
66 | | - actionName: string, |
67 | | - actionIcon: string, |
68 | | -): { children: string[], elements: Record<string, JsonRenderElement> } { |
69 | | - const children: string[] = [] |
70 | | - const elements: Record<string, JsonRenderElement> = {} |
71 | | - |
72 | | - for (let i = 0; i < files.length; i++) { |
73 | | - const { status, file } = files[i] |
74 | | - const rowId = `${prefix}-row-${i}` |
75 | | - const statusId = `${prefix}-status-${i}` |
76 | | - const fileId = `${prefix}-file-${i}` |
77 | | - const btnId = `${prefix}-btn-${i}` |
78 | | - |
79 | | - children.push(rowId) |
80 | | - elements[rowId] = { |
81 | | - type: 'Stack', |
82 | | - props: { direction: 'horizontal', gap: 8, align: 'center' }, |
83 | | - children: [statusId, fileId, btnId], |
84 | | - } |
85 | | - elements[statusId] = { |
86 | | - type: 'Badge', |
87 | | - props: { |
88 | | - text: status, |
89 | | - variant: status === '?' ? 'warning' : status === 'D' ? 'error' : 'info', |
90 | | - }, |
91 | | - } |
92 | | - elements[fileId] = { |
93 | | - type: 'Text', |
94 | | - props: { content: file, variant: 'code' }, |
95 | | - } |
96 | | - elements[btnId] = { |
97 | | - type: 'Button', |
98 | | - props: { icon: actionIcon, variant: 'ghost' }, |
99 | | - on: { press: { action: actionName, params: { file } } }, |
100 | | - } |
101 | | - } |
102 | | - |
103 | | - return { children, elements } |
104 | | -} |
105 | | - |
106 | | -function buildSpec(gitState: GitState): JsonRenderSpec { |
107 | | - const stagedRows = buildFileRows(gitState.staged, 'staged', 'git-ui:unstage', 'ph:minus-circle') |
108 | | - const unstagedRows = buildFileRows(gitState.unstaged, 'unstaged', 'git-ui:stage', 'ph:plus-circle') |
109 | | - |
110 | | - return defineJsonRenderSpec({ |
111 | | - root: 'root', |
112 | | - state: { |
113 | | - commitMessage: '', |
114 | | - }, |
115 | | - elements: { |
116 | | - 'root': { |
117 | | - type: 'Stack', |
118 | | - props: { direction: 'vertical', gap: 12, padding: 4 }, |
119 | | - children: ['header', 'branch-info', 'commit-section', 'divider1', 'staged-card', 'unstaged-card', 'commits-card'], |
120 | | - }, |
121 | | - 'header': { |
122 | | - type: 'Stack', |
123 | | - props: { direction: 'horizontal', gap: 8, align: 'center', justify: 'space-between' }, |
124 | | - children: ['title', 'refresh-btn'], |
125 | | - }, |
126 | | - 'title': { |
127 | | - type: 'Text', |
128 | | - props: { content: 'Git', variant: 'heading' }, |
129 | | - }, |
130 | | - 'refresh-btn': { |
131 | | - type: 'Button', |
132 | | - props: { label: 'Refresh', variant: 'secondary', icon: 'ph:arrows-clockwise' }, |
133 | | - on: { press: { action: 'git-ui:refresh' } }, |
134 | | - }, |
135 | | - 'branch-info': { |
136 | | - type: 'Stack', |
137 | | - props: { direction: 'horizontal', gap: 8, align: 'center' }, |
138 | | - children: ['branch-icon', 'branch-text', 'changes-badge'], |
139 | | - }, |
140 | | - 'branch-icon': { |
141 | | - type: 'Icon', |
142 | | - props: { name: 'ph:git-branch', size: 16 }, |
143 | | - }, |
144 | | - 'branch-text': { |
145 | | - type: 'Text', |
146 | | - props: { content: gitState.branch || '(detached)', variant: 'code' }, |
147 | | - }, |
148 | | - 'changes-badge': { |
149 | | - type: 'Badge', |
150 | | - props: { |
151 | | - text: `${gitState.staged.length + gitState.unstaged.length} changes`, |
152 | | - variant: (gitState.staged.length + gitState.unstaged.length) > 0 ? 'warning' : 'success', |
153 | | - }, |
154 | | - }, |
155 | | - 'commit-section': { |
156 | | - type: 'Stack', |
157 | | - props: { direction: 'horizontal', gap: 8 }, |
158 | | - children: ['commit-input', 'commit-btn'], |
159 | | - }, |
160 | | - 'commit-input': { |
161 | | - type: 'TextInput', |
162 | | - props: { |
163 | | - placeholder: 'Commit message...', |
164 | | - value: { $bindState: '/commitMessage' } as any, |
165 | | - }, |
166 | | - }, |
167 | | - 'commit-btn': { |
168 | | - type: 'Button', |
169 | | - props: { label: 'Commit', variant: 'primary', icon: 'ph:check' }, |
170 | | - on: { |
171 | | - press: { |
172 | | - action: 'git-ui:commit', |
173 | | - params: { message: { $state: '/commitMessage' } }, |
174 | | - }, |
175 | | - }, |
176 | | - }, |
177 | | - 'divider1': { |
178 | | - type: 'Divider', |
179 | | - props: {}, |
180 | | - }, |
181 | | - |
182 | | - // Staged files |
183 | | - 'staged-card': { |
184 | | - type: 'Card', |
185 | | - props: { title: `Staged (${gitState.staged.length})`, collapsible: true }, |
186 | | - children: gitState.staged.length > 0 ? ['staged-files'] : ['staged-empty'], |
187 | | - }, |
188 | | - 'staged-files': { |
189 | | - type: 'Stack', |
190 | | - props: { direction: 'vertical', gap: 4 }, |
191 | | - children: stagedRows.children, |
192 | | - }, |
193 | | - ...stagedRows.elements, |
194 | | - 'staged-empty': { |
195 | | - type: 'Text', |
196 | | - props: { content: 'No staged files', variant: 'caption' }, |
197 | | - }, |
198 | | - |
199 | | - // Unstaged files |
200 | | - 'unstaged-card': { |
201 | | - type: 'Card', |
202 | | - props: { title: `Unstaged (${gitState.unstaged.length})`, collapsible: true }, |
203 | | - children: gitState.unstaged.length > 0 ? ['unstaged-header', 'unstaged-files'] : ['unstaged-empty'], |
204 | | - }, |
205 | | - 'unstaged-header': { |
206 | | - type: 'Stack', |
207 | | - props: { direction: 'horizontal', justify: 'end' }, |
208 | | - children: ['stage-all-btn'], |
209 | | - }, |
210 | | - 'stage-all-btn': { |
211 | | - type: 'Button', |
212 | | - props: { label: 'Stage All', variant: 'secondary', icon: 'ph:plus-circle' }, |
213 | | - on: { press: { action: 'git-ui:stage-all' } }, |
214 | | - }, |
215 | | - 'unstaged-files': { |
216 | | - type: 'Stack', |
217 | | - props: { direction: 'vertical', gap: 4 }, |
218 | | - children: unstagedRows.children, |
219 | | - }, |
220 | | - ...unstagedRows.elements, |
221 | | - 'unstaged-empty': { |
222 | | - type: 'Text', |
223 | | - props: { content: 'No unstaged files', variant: 'caption' }, |
224 | | - }, |
225 | | - |
226 | | - // Commits |
227 | | - 'commits-card': { |
228 | | - type: 'Card', |
229 | | - props: { title: 'Recent Commits', collapsible: true }, |
230 | | - children: ['commits-table'], |
231 | | - }, |
232 | | - 'commits-table': { |
233 | | - type: 'DataTable', |
234 | | - props: { |
235 | | - columns: [ |
236 | | - { key: 'hash', label: 'Hash', width: '80px' }, |
237 | | - { key: 'message', label: 'Message' }, |
238 | | - { key: 'author', label: 'Author', width: '120px' }, |
239 | | - { key: 'date', label: 'Date', width: '100px' }, |
240 | | - ], |
241 | | - rows: gitState.commits, |
242 | | - maxHeight: '300px', |
243 | | - }, |
244 | | - }, |
245 | | - }, |
246 | | - }) |
247 | | -} |
| 4 | +import { getGitState, git } from './git' |
| 5 | +import { buildSpec } from './spec' |
248 | 6 |
|
249 | 7 | async function refreshUi(ctx: { cwd: string, docks: any }, ui: JsonRenderer) { |
250 | 8 | const gitState = await getGitState(ctx.cwd) |
|
0 commit comments