dsh-remote-vs 0.0.0-stage → 0.8.3

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.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * History view: the commit graph with branch lanes and merge curves (lane
3
+ * layout ported from the previous Git view), plus click-to-expand changed
4
+ * files and a per-file diff of that commit.
5
+ *
6
+ * Loading is a SINGLE capped call (`api.git.log(..., GIT_LOG_MAX)`); a deep
7
+ * repository shows the newest GIT_LOG_MAX commits and is filtered client-side.
8
+ * The docstring here used to promise scroll-triggered paging that does not
9
+ * exist, which is worse than no comment: a reader trusts it.
10
+ */
11
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
12
+ import type { GitCommit, GitCommitFile } from '../../protocol.ts'
13
+ import { GIT_LOG_MAX } from '../../git-format.ts'
14
+ import { api } from '../api.ts'
15
+ import { useExplorer } from '../store.tsx'
16
+ import { checkoutCommit, loadCommitFiles } from './actions.ts'
17
+ import {
18
+ IconButton,
19
+ IconChevronDown,
20
+ IconChevronRight,
21
+ IconRefresh,
22
+ MiniSpinner,
23
+ confirmDialog,
24
+ FileIcon,
25
+ gitStatusClass,
26
+ relTime,
27
+ toast,
28
+ } from '../vsc/kit.tsx'
29
+
30
+ const ROW_H = 22
31
+ const LANE_W = 13
32
+ const PAD_X = 9
33
+ const LANE_COLORS = ['#e2b714', '#4ec9b0', '#58a6ff', '#d2a8ff', '#ff7b72', '#79c0ff', '#7ee787', '#ffa657']
34
+
35
+ /**
36
+ * Ref badge decoration for one `%D` entry. Extracted from a pair of nested
37
+ * ternaries (forbidden by this project's review checklist) that were doing two
38
+ * different jobs — CSS modifier and display text — inside one JSX expression.
39
+ */
40
+ function refBadgeClass(ref: string): string {
41
+ if (ref.startsWith('HEAD ->')) return ' head'
42
+ if (ref.startsWith('tag:')) return ' tag'
43
+ return ''
44
+ }
45
+
46
+ function refBadgeLabel(ref: string): string {
47
+ if (ref.startsWith('HEAD -> ')) return ref.slice('HEAD -> '.length)
48
+ return ref.replace(/^tag: /, '')
49
+ }
50
+
51
+ interface GraphRow {
52
+ commit: GitCommit
53
+ lane: number
54
+ before: (string | null)[]
55
+ after: (string | null)[]
56
+ merges: { lane: number; fresh: boolean }[]
57
+ cleared: number[]
58
+ }
59
+
60
+ /** Assign one lane per commit line; extra parents open or ride lanes. */
61
+ function layoutLanes(commits: GitCommit[]): { rows: GraphRow[]; laneCount: number } {
62
+ const lanes: (string | null)[] = []
63
+ const rows: GraphRow[] = []
64
+ for (const commit of commits) {
65
+ let i = lanes.indexOf(commit.hash)
66
+ if (i === -1) {
67
+ const free = lanes.indexOf(null)
68
+ if (free === -1) {
69
+ lanes.push(commit.hash)
70
+ i = lanes.length - 1
71
+ } else {
72
+ lanes[free] = commit.hash
73
+ i = free
74
+ }
75
+ }
76
+ const before = [...lanes]
77
+ const merges: GraphRow['merges'] = []
78
+ const cleared: number[] = []
79
+ const parents = commit.parents
80
+ if (parents.length === 0) {
81
+ lanes[i] = null
82
+ } else {
83
+ lanes[i] = parents[0] as string
84
+ for (let k = 0; k < lanes.length; k++) {
85
+ if (k !== i && lanes[k] === parents[0]) {
86
+ lanes[k] = null
87
+ cleared.push(k)
88
+ }
89
+ }
90
+ for (let p = 1; p < parents.length; p++) {
91
+ const parentHash = parents[p] as string
92
+ const j = lanes.indexOf(parentHash)
93
+ if (j === -1) {
94
+ const free = lanes.indexOf(null)
95
+ if (free === -1) {
96
+ lanes.push(parentHash)
97
+ merges.push({ lane: lanes.length - 1, fresh: true })
98
+ } else {
99
+ lanes[free] = parentHash
100
+ merges.push({ lane: free, fresh: true })
101
+ }
102
+ } else {
103
+ merges.push({ lane: j, fresh: false })
104
+ }
105
+ }
106
+ }
107
+ rows.push({ commit, lane: i, before, after: [...lanes], merges, cleared })
108
+ }
109
+ let used = 1
110
+ for (const row of rows) {
111
+ used = Math.max(used, row.lane + 1)
112
+ row.before.forEach((hash, j) => { if (hash !== null) used = Math.max(used, j + 1) })
113
+ row.after.forEach((hash, j) => { if (hash !== null) used = Math.max(used, j + 1) })
114
+ for (const merge of row.merges) used = Math.max(used, merge.lane + 1)
115
+ }
116
+ return { rows, laneCount: used }
117
+ }
118
+
119
+ function laneColor(lane: number): string {
120
+ return LANE_COLORS[lane % LANE_COLORS.length] as string
121
+ }
122
+
123
+ export function HistoryPanel(): ReactNode {
124
+ const store = useExplorer()
125
+ const { t, lang, alias, repoRoot } = store
126
+ const [commits, setCommits] = useState<GitCommit[]>([])
127
+ const [loading, setLoading] = useState(false)
128
+ const [error, setError] = useState<string | undefined>(undefined)
129
+ const [expanded, setExpanded] = useState<string | undefined>(undefined)
130
+ const [files, setFiles] = useState<Record<string, GitCommitFile[]>>({})
131
+ const [filter, setFilter] = useState('')
132
+ const head = store.head?.branch ?? ''
133
+ /**
134
+ * Ticket for the log request. The panel stays mounted across binding
135
+ * switches, so two `git log` calls can overlap; without this the slower one
136
+ * lands last and the tab lists the OTHER workspace's commits, with checkout
137
+ * buttons that act on the currently bound repository.
138
+ */
139
+ const loadSeq = useRef(0)
140
+
141
+ const load = useCallback(async (): Promise<void> => {
142
+ if (alias === '' || repoRoot === undefined) {
143
+ loadSeq.current += 1
144
+ setCommits([])
145
+ setLoading(false)
146
+ return
147
+ }
148
+ const ticket = ++loadSeq.current
149
+ setLoading(true)
150
+ setError(undefined)
151
+ try {
152
+ const list = await api.git.log(alias, repoRoot, GIT_LOG_MAX)
153
+ if (loadSeq.current !== ticket) return
154
+ setCommits(list)
155
+ } catch (caught) {
156
+ if (loadSeq.current !== ticket) return
157
+ setCommits([])
158
+ setError(caught instanceof Error ? caught.message : String(caught))
159
+ } finally {
160
+ if (loadSeq.current === ticket) setLoading(false)
161
+ }
162
+ }, [alias, repoRoot])
163
+
164
+ useEffect(() => {
165
+ void load()
166
+ }, [load])
167
+
168
+ const visible = useMemo(() => {
169
+ if (filter.trim() === '') return commits
170
+ const needle = filter.trim().toLowerCase()
171
+ return commits.filter(commit =>
172
+ commit.subject.toLowerCase().includes(needle)
173
+ || commit.author.toLowerCase().includes(needle)
174
+ || commit.hash.startsWith(needle))
175
+ }, [commits, filter])
176
+
177
+ const graph = useMemo(() => layoutLanes(visible), [visible])
178
+
179
+ const toggleCommit = async (hash: string): Promise<void> => {
180
+ if (expanded === hash) {
181
+ setExpanded(undefined)
182
+ return
183
+ }
184
+ setExpanded(hash)
185
+ if (files[hash] !== undefined || repoRoot === undefined) return
186
+ try {
187
+ const rows = await loadCommitFiles(alias, repoRoot, hash)
188
+ setFiles(prev => ({ ...prev, [hash]: rows }))
189
+ } catch (error) {
190
+ toast(error instanceof Error ? error.message : String(error), true)
191
+ }
192
+ }
193
+
194
+ const x = (lane: number): number => PAD_X + lane * LANE_W
195
+ const y = (row: number): number => row * ROW_H + ROW_H / 2
196
+
197
+ return (
198
+ <div className="rvs-pane grow">
199
+ <div className="rvs-header">
200
+ <span className="rvs-title">{t('tab.history')}</span>
201
+ {head !== '' && <span className="rvs-pill">⑂ {store.head?.detached === true ? t('scm.detached') : head}</span>}
202
+ <IconButton title={t('scm.refresh')} onClick={() => { void load() }} disabled={loading}>
203
+ <IconRefresh size={14} />
204
+ </IconButton>
205
+ </div>
206
+ <div className="rvs-search-row">
207
+ <input
208
+ className="rvs-input"
209
+ placeholder={t('history.filter')}
210
+ value={filter}
211
+ spellCheck={false}
212
+ onChange={(e) => setFilter(e.target.value)}
213
+ />
214
+ </div>
215
+ {error !== undefined && <div className="rvs-banner error">{error}</div>}
216
+ <div className="rvs-scroll">
217
+ {loading && commits.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
218
+ {!loading && commits.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
219
+ {graph.rows.map((row, index) => {
220
+ const commit = row.commit
221
+ const isOpen = expanded === commit.hash
222
+ const changed = files[commit.hash]
223
+ return (
224
+ <div key={commit.hash}>
225
+ <button
226
+ type="button"
227
+ className={`rvs-commit-item${isOpen ? ' selected' : ''}`}
228
+ title={`${commit.hash}\n${commit.author} <${commit.email}>\n${commit.subject}`}
229
+ onClick={() => { void toggleCommit(commit.hash) }}
230
+ >
231
+ <span className="rvs-node-icon" style={{ width: 12 }}>
232
+ {isOpen ? <IconChevronDown size={11} /> : <IconChevronRight size={11} />}
233
+ </span>
234
+ <svg className="rvs-graph-svg" width={PAD_X * 2 + (graph.laneCount - 1) * LANE_W} height={ROW_H}>
235
+ {row.before.map((hash, lane) => {
236
+ const after = row.after[lane] ?? null
237
+ if (hash === null || after === null || hash !== after) return null
238
+ return (
239
+ <line
240
+ key={`v${lane}`}
241
+ x1={x(lane)}
242
+ y1={y(index) - ROW_H / 2}
243
+ x2={x(lane)}
244
+ y2={y(index) + ROW_H / 2}
245
+ stroke={laneColor(lane)}
246
+ strokeWidth="2"
247
+ />
248
+ )
249
+ })}
250
+ {index > 0 && row.before[row.lane] != null && (
251
+ <line x1={x(row.lane)} y1={y(index) - ROW_H / 2} x2={x(row.lane)} y2={y(index)} stroke={laneColor(row.lane)} strokeWidth="2" />
252
+ )}
253
+ {row.after[row.lane] != null && (
254
+ <line x1={x(row.lane)} y1={y(index)} x2={x(row.lane)} y2={y(index) + ROW_H / 2} stroke={laneColor(row.lane)} strokeWidth="2" />
255
+ )}
256
+ {row.cleared.map(lane => (
257
+ <path
258
+ key={`c${lane}`}
259
+ d={`M ${x(lane)} ${y(index) - ROW_H / 2} Q ${x(lane)} ${y(index)} ${x(row.lane)} ${y(index)}`}
260
+ fill="none"
261
+ stroke={laneColor(lane)}
262
+ strokeWidth="2"
263
+ />
264
+ ))}
265
+ {row.merges.map(merge => (
266
+ <path
267
+ key={`m${merge.lane}`}
268
+ d={merge.fresh
269
+ ? `M ${x(row.lane)} ${y(index)} Q ${x(row.lane)} ${y(index) + ROW_H / 2} ${x(merge.lane)} ${y(index) + ROW_H / 2}`
270
+ : `M ${x(row.lane)} ${y(index)} Q ${(x(row.lane) + x(merge.lane)) / 2} ${y(index) + 9} ${x(merge.lane)} ${y(index)}`}
271
+ fill="none"
272
+ stroke={laneColor(merge.lane)}
273
+ strokeWidth="2"
274
+ />
275
+ ))}
276
+ <circle cx={x(row.lane)} cy={y(index)} r="3.4" fill={laneColor(row.lane)} />
277
+ </svg>
278
+ {commit.refs.length > 0 && (
279
+ <span style={{ display: 'flex', gap: 3, flex: 'none' }}>
280
+ {commit.refs.slice(0, 2).map(ref => (
281
+ <span
282
+ key={ref}
283
+ className={`rvs-ref-badge${refBadgeClass(ref)}`}
284
+ >
285
+ {refBadgeLabel(ref)}
286
+ </span>
287
+ ))}
288
+ </span>
289
+ )}
290
+ <span className="rvs-commit-hash">{commit.hash.slice(0, 7)}</span>
291
+ <span className="rvs-commit-msg">{commit.subject}</span>
292
+ <span className="rvs-commit-date">{relTime(commit.date, lang)}</span>
293
+ </button>
294
+ {isOpen && (
295
+ <div className="rvs-detail">
296
+ <div className="rvs-detail-meta">
297
+ {commit.author} &lt;{commit.email}&gt; · {relTime(commit.date, lang)} · {commit.hash.slice(0, 10)}
298
+ </div>
299
+ {changed === undefined
300
+ ? <div className="rvs-empty" style={{ padding: 8 }}><MiniSpinner /> {t('common.loading')}</div>
301
+ : changed.length === 0
302
+ ? <div className="rvs-empty" style={{ padding: 8 }}>{t('scm.logEmpty')}</div>
303
+ : changed.slice(0, 200).map(file => (
304
+ <div
305
+ key={`${file.status}:${file.path}`}
306
+ className="rvs-file-row"
307
+ title={file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
308
+ onClick={() => { store.openCommitFile(commit.hash, file.path) }}
309
+ >
310
+ <span className="rvs-node-icon"><FileIcon path={file.path} size={13} /></span>
311
+ <span className="rvs-file-path">
312
+ {file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
313
+ </span>
314
+ <span className={`rvs-file-status rvs-letter-${gitStatusClass(file.status)}`}>{file.status[0]}</span>
315
+ </div>
316
+ ))}
317
+ <div className="rvs-commit-row">
318
+ <button
319
+ type="button"
320
+ className="rvs-chip"
321
+ onClick={() => {
322
+ void (async () => {
323
+ const ok = await confirmDialog({
324
+ title: t('scm.checkoutCommitTitle'),
325
+ text: t('scm.checkoutCommitText').replace('{hash}', commit.hash.slice(0, 10)),
326
+ okText: t('scm.checkout'),
327
+ })
328
+ if (!ok || repoRoot === undefined) return
329
+ try {
330
+ await checkoutCommit(alias, repoRoot, commit.hash)
331
+ await store.afterWrite()
332
+ toast(t('scm.checkedOut'))
333
+ } catch (caught) {
334
+ toast(caught instanceof Error ? caught.message : String(caught), true)
335
+ }
336
+ })()
337
+ }}
338
+ >
339
+ {t('scm.checkout')}
340
+ </button>
341
+ </div>
342
+ </div>
343
+ )}
344
+ </div>
345
+ )
346
+ })}
347
+ {visible.length >= GIT_LOG_MAX && (
348
+ <div className="rvs-empty">{t('scm.logCapped').replace('{n}', String(GIT_LOG_MAX))}</div>
349
+ )}
350
+ </div>
351
+ </div>
352
+ )
353
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Repository directory picker (SCM): a workspace binding is often a container
3
+ * (`/srv/sites`) while the repository sits one or more levels below it
4
+ * (`/srv/sites/shop/.git`). Git only answers for the directory it runs in, so
5
+ * this dialog browses the REMOTE tree under the workspace root and reports, per
6
+ * directory, which repository it would resolve to before the user commits.
7
+ *
8
+ * Three ways in, because they cover different situations:
9
+ * - the discovered-repository list (one `find` under the workspace root, max
10
+ * depth 3) for the common case,
11
+ * - drilling through directories (dirs only, `..` included),
12
+ * - typing or pasting a path (relative or absolute inside the workspace) when
13
+ * the tree is deep.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
16
+ import { api } from '../api.ts'
17
+ import { normalizeRepoSubdir, repoSubdirOf, repoWorkdir } from '../../git-ops.ts'
18
+ import { resolveRepoRoot } from './actions.ts'
19
+ import { IconButton, IconChevronRight, IconClose, IconFolder, IconRefresh, FolderIcon, MiniSpinner } from '../vsc/kit.tsx'
20
+ import { useExplorer } from '../store.tsx'
21
+
22
+ /** What git resolves to for the directory currently being browsed. */
23
+ type Probe =
24
+ | { kind: 'loading' }
25
+ | { kind: 'root' }
26
+ | { kind: 'inside'; subdir: string }
27
+ | { kind: 'none' }
28
+ | { kind: 'error'; message: string }
29
+
30
+ export function RepoPicker(props: {
31
+ alias: string
32
+ /** Absolute workspace root (the tree the picker may browse). */
33
+ root: string
34
+ /** Currently selected workspace-relative directory ('' = the root). */
35
+ current: string
36
+ onPick: (subdir: string) => void
37
+ onClose: () => void
38
+ }): ReactNode {
39
+ const store = useExplorer()
40
+ const { t, discoverRepos } = store
41
+ const [cwd, setCwd] = useState<string>(() => repoWorkdir(props.root, props.current))
42
+ const [dirs, setDirs] = useState<{ name: string; path: string }[]>([])
43
+ const [listing, setListing] = useState(false)
44
+ const [probe, setProbe] = useState<Probe>({ kind: 'loading' })
45
+ const [candidates, setCandidates] = useState<string[] | undefined>(undefined)
46
+ const [finding, setFinding] = useState(false)
47
+ const [typed, setTyped] = useState('')
48
+ const [error, setError] = useState<string | undefined>(undefined)
49
+
50
+ const rel = repoSubdirOf(props.root, cwd) ?? ''
51
+ const insideRoot = repoSubdirOf(props.root, cwd) !== undefined
52
+
53
+ // Directory listing + repository probe for the browsed directory.
54
+ useEffect(() => {
55
+ let cancelled = false
56
+ setListing(true)
57
+ setProbe({ kind: 'loading' })
58
+ api.fileOp.list(props.alias, cwd).then(
59
+ (entries) => {
60
+ if (cancelled) return
61
+ setDirs(
62
+ entries
63
+ .filter(entry => entry.isDir)
64
+ .map(entry => ({ name: entry.name, path: entry.path }))
65
+ .sort((a, b) => a.name.localeCompare(b.name)),
66
+ )
67
+ setListing(false)
68
+ },
69
+ (e: unknown) => {
70
+ if (cancelled) return
71
+ setDirs([])
72
+ setListing(false)
73
+ setError(e instanceof Error ? e.message : String(e))
74
+ },
75
+ )
76
+ resolveRepoRoot(props.alias, cwd).then(
77
+ (found) => {
78
+ if (cancelled) return
79
+ if (found === undefined) setProbe({ kind: 'none' })
80
+ else if (found === cwd) setProbe({ kind: 'root' })
81
+ else setProbe({ kind: 'inside', subdir: repoSubdirOf(props.root, found) ?? found })
82
+ },
83
+ (e: unknown) => {
84
+ if (cancelled) return
85
+ setProbe({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
86
+ },
87
+ )
88
+ return () => { cancelled = true }
89
+ }, [props.alias, props.root, cwd])
90
+
91
+ const find = useCallback(async (): Promise<void> => {
92
+ setFinding(true)
93
+ try {
94
+ setCandidates(await discoverRepos())
95
+ } finally {
96
+ setFinding(false)
97
+ }
98
+ }, [discoverRepos])
99
+
100
+ // Discovery runs once when the dialog opens, and again only when the dialog
101
+ // is re-pointed at another binding: it is the shortest path for the "my
102
+ // repository is in a subdirectory somewhere" case. The dependency used to be
103
+ // the whole `store`, whose snapshot is a NEW object on every publish — so
104
+ // every background git refresh under an open dialog re-ran a remote
105
+ // `git rev-parse` (plus a `find -maxdepth 3` when the root is not a repo).
106
+ // It does not depend on `cwd`: the scan starts at the workspace root.
107
+ useEffect(() => { void find() }, [find, props.alias, props.root])
108
+
109
+ const parent = useMemo(() => {
110
+ if (!insideRoot) return undefined
111
+ if (rel === '') return undefined
112
+ const cut = rel.lastIndexOf('/')
113
+ return repoWorkdir(props.root, cut === -1 ? '' : rel.slice(0, cut))
114
+ }, [insideRoot, rel, props.root])
115
+
116
+ const jump = (input: string): void => {
117
+ const value = input.trim()
118
+ if (value === '') return
119
+ if (value.startsWith('/')) {
120
+ const subdir = repoSubdirOf(props.root, value.replace(/\/+$/, ''))
121
+ if (subdir === undefined) {
122
+ setError(t('scm.pickOutside'))
123
+ return
124
+ }
125
+ setError(undefined)
126
+ setCwd(repoWorkdir(props.root, subdir))
127
+ setTyped('')
128
+ return
129
+ }
130
+ setError(undefined)
131
+ setCwd(repoWorkdir(props.root, normalizeRepoSubdir(value)))
132
+ setTyped('')
133
+ }
134
+
135
+ const crumbs = rel === '' ? [] : rel.split('/')
136
+
137
+ return (
138
+ <div className="rvs-modal-mask" onMouseDown={(e) => { if (e.target === e.currentTarget) props.onClose() }}>
139
+ <div className="rvs-modal-box rvs-repopick" role="dialog" aria-label={t('scm.pickTitle')}>
140
+ <div className="rvs-modal-title">
141
+ {t('scm.pickTitle')}
142
+ <IconButton title={t('common.close')} onClick={props.onClose}><IconClose size={13} /></IconButton>
143
+ </div>
144
+
145
+ <div className="rvs-repopick-bar">
146
+ <button type="button" className="rvs-crumb" onClick={() => setCwd(props.root)}>{t('scm.repoDirRoot')}</button>
147
+ {crumbs.map((part, index) => (
148
+ <span key={`${part}-${index}`} className="rvs-crumb-wrap">
149
+ <IconChevronRight size={11} />
150
+ <button
151
+ type="button"
152
+ className="rvs-crumb"
153
+ onClick={() => setCwd(repoWorkdir(props.root, crumbs.slice(0, index + 1).join('/')))}
154
+ >
155
+ {part}
156
+ </button>
157
+ </span>
158
+ ))}
159
+ {listing && <MiniSpinner />}
160
+ </div>
161
+
162
+ <div className={`rvs-repopick-probe ${probe.kind}`}>
163
+ {probe.kind === 'loading' && t('common.loading')}
164
+ {probe.kind === 'root' && `✓ ${t('scm.pickRoot')}`}
165
+ {probe.kind === 'inside' && `${t('scm.pickInside')} ${probe.subdir === '' ? t('scm.repoDirRoot') : probe.subdir}`}
166
+ {probe.kind === 'none' && t('scm.pickNone')}
167
+ {probe.kind === 'error' && probe.message}
168
+ </div>
169
+
170
+ {candidates !== undefined && (
171
+ <div className="rvs-repopick-found">
172
+ <span className="rvs-repopick-found-label">{t('scm.discoverFound')}</span>
173
+ {candidates.length === 0
174
+ ? <span className="rvs-repopick-dim">{t('scm.discoverEmpty')}</span>
175
+ : candidates.map(dir => (
176
+ <button
177
+ key={dir === '' ? '.' : dir}
178
+ type="button"
179
+ className="rvs-chip"
180
+ title={repoWorkdir(props.root, dir)}
181
+ onClick={() => setCwd(repoWorkdir(props.root, dir))}
182
+ >
183
+ {dir === '' ? t('scm.repoDirRoot') : dir}
184
+ </button>
185
+ ))}
186
+ <IconButton title={t('scm.discover')} onClick={() => { void find() }} disabled={finding}>
187
+ <IconRefresh size={12} />
188
+ </IconButton>
189
+ </div>
190
+ )}
191
+
192
+ <div className="rvs-repopick-list">
193
+ {parent !== undefined && (
194
+ <button type="button" className="rvs-repopick-row up" onClick={() => setCwd(parent)}>
195
+ <IconFolder size={14} /><span>..</span>
196
+ </button>
197
+ )}
198
+ {dirs.map(dir => (
199
+ <button key={dir.path} type="button" className="rvs-repopick-row" title={dir.path} onClick={() => setCwd(dir.path)}>
200
+ <FolderIcon path={dir.path} open={false} size={14} />
201
+ <span>{dir.name}</span>
202
+ </button>
203
+ ))}
204
+ {!listing && dirs.length === 0 && <div className="rvs-repopick-dim">{t('scm.pickEmptyDir')}</div>}
205
+ </div>
206
+
207
+ <form
208
+ className="rvs-repopick-type"
209
+ onSubmit={(e) => { e.preventDefault(); jump(typed) }}
210
+ >
211
+ <input
212
+ className="rvs-input"
213
+ value={typed}
214
+ spellCheck={false}
215
+ placeholder={t('scm.pickPathPlaceholder')}
216
+ onChange={(e) => setTyped(e.target.value)}
217
+ />
218
+ <button type="submit" className="rvs-chip" disabled={typed.trim() === ''}>{t('scm.pickGo')}</button>
219
+ </form>
220
+
221
+ {error !== undefined && <div className="rvs-repopick-error">{error}</div>}
222
+
223
+ <div className="rvs-modal-row">
224
+ <button type="button" className="rvs-modal-btn" onClick={props.onClose}>{t('scm.pickCancel')}</button>
225
+ <button
226
+ type="button"
227
+ className="rvs-modal-btn primary"
228
+ disabled={!insideRoot}
229
+ title={cwd}
230
+ onClick={() => props.onPick(rel)}
231
+ >
232
+ {t('scm.pickChoose')}
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ )
238
+ }