dsh-git-changes 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.en.md +105 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/vendor/monaco/LICENSE +21 -0
- package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
- package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
- package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
- package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
- package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
- package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
- package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
- package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
- package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
- package/assets/vendor/monaco/vs/loader.js +10 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/git-changes-diff.png +0 -0
- package/icon.svg +10 -0
- package/lib/client.js +9418 -0
- package/lib/host/assets.js +161 -0
- package/lib/host/commit-message.js +158 -0
- package/lib/host/files-index.js +206 -0
- package/lib/host/git-write.js +194 -0
- package/lib/host/git.js +1147 -0
- package/lib/host/lsp.js +842 -0
- package/lib/host/routes.js +976 -0
- package/lib/host/session.js +84 -0
- package/lib/host/util.js +107 -0
- package/lib/index.js +33 -0
- package/licenses/dsh-vscode-mode-LICENSE +21 -0
- package/licenses/vscode-theme-seti-LICENSE +21 -0
- package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -3
- package/src/client/00-const.js +79 -0
- package/src/client/05-utils.js +277 -0
- package/src/client/10-i18n.js +548 -0
- package/src/client/12-vscode-icons.js +460 -0
- package/src/client/15-theme.js +509 -0
- package/src/client/20-monaco.js +1404 -0
- package/src/client/22-lsp.js +592 -0
- package/src/client/25-tree.js +563 -0
- package/src/client/28-changes.js +1163 -0
- package/src/client/30-page.js +2259 -0
- package/src/client/35-pill.js +185 -0
- package/src/client/40-commands.js +248 -0
- package/src/client/45-css.js +357 -0
- package/src/client/47-pickers.js +457 -0
- package/src/client/50-apply.js +207 -0
- package/src/client/99-footer.js +62 -0
|
@@ -0,0 +1,2259 @@
|
|
|
1
|
+
/* dsh-git-changes client half — the review page (toolbar, code area, tree, status). */
|
|
2
|
+
|
|
3
|
+
/** Cross-component runtime state: the mounted page controller plus optional services. */
|
|
4
|
+
const runtimeState = {
|
|
5
|
+
page: null,
|
|
6
|
+
sessionId: null,
|
|
7
|
+
right: undefined,
|
|
8
|
+
tabs: undefined,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-session page state that survives the right-sidebar tab body unmounting.
|
|
13
|
+
* DSH tears down the previous conversation's sidebar tab bodies when the user
|
|
14
|
+
* switches sessions (and may unmount inactive tab bodies when switching tabs);
|
|
15
|
+
* without this store every remount started from scratch, so a session switch
|
|
16
|
+
* looked like "all open files closed + git version reset". Memory only: the
|
|
17
|
+
* stored paths belong to a live workspace/commit, never to a DSH restart.
|
|
18
|
+
*/
|
|
19
|
+
const pageSessionStore = new Map()
|
|
20
|
+
/** Upper bound of remembered sessions (LRU eviction keeps memory bounded). */
|
|
21
|
+
const PAGE_SESSION_STORE_LIMIT = 40
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read one session's remembered page state (LRU-touched).
|
|
25
|
+
* @param {string} sessionId session identity
|
|
26
|
+
* @returns {object | null} remembered state or null
|
|
27
|
+
*/
|
|
28
|
+
function rememberedPageState(sessionId) {
|
|
29
|
+
const key = sessionId ? String(sessionId) : ''
|
|
30
|
+
if (!key) return null
|
|
31
|
+
const value = pageSessionStore.get(key)
|
|
32
|
+
if (!value) return null
|
|
33
|
+
pageSessionStore.delete(key)
|
|
34
|
+
pageSessionStore.set(key, value)
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remember one session's page state for the next mount of this tab body.
|
|
40
|
+
* @param {string} sessionId session identity
|
|
41
|
+
* @param {object | null} state page state snapshot
|
|
42
|
+
*/
|
|
43
|
+
function rememberPageState(sessionId, state) {
|
|
44
|
+
const key = sessionId ? String(sessionId) : ''
|
|
45
|
+
if (!key || !state) return
|
|
46
|
+
pageSessionStore.delete(key)
|
|
47
|
+
pageSessionStore.set(key, state)
|
|
48
|
+
while (pageSessionStore.size > PAGE_SESSION_STORE_LIMIT) {
|
|
49
|
+
const oldest = pageSessionStore.keys().next()
|
|
50
|
+
if (oldest.done) break
|
|
51
|
+
pageSessionStore.delete(oldest.value)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read the last-used comparison baseline (survives app restarts). */
|
|
56
|
+
function storedBaseline() {
|
|
57
|
+
try {
|
|
58
|
+
const value = localStorage.getItem('dsh-git-changes.mode')
|
|
59
|
+
return ['turn', 'git', 'revision', 'code'].includes(value) ? value : ''
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return ''
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read the last-used staged/unstaged scope for git comparisons. */
|
|
66
|
+
function storedChangeScope() {
|
|
67
|
+
try {
|
|
68
|
+
return localStorage.getItem('dsh-git-changes.changeScope') === 'staged' ? 'staged' : 'unstaged'
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return 'unstaged'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The client plugin context captured by `apply` (used for optional event subscriptions). */
|
|
75
|
+
const globalCtxRef = { current: null }
|
|
76
|
+
|
|
77
|
+
/** Last known copied-path feedback timer (module scope so remounts do not stack). */
|
|
78
|
+
let copiedTimer = null
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Editor cursor position as a tiny external store: the status bar is the only
|
|
82
|
+
* consumer, so a click/selection in the editor re-renders that one line instead
|
|
83
|
+
* of the whole review page (file tree + tab strip included). That full-page
|
|
84
|
+
* re-render was the reason a click could take a visible moment to paint the
|
|
85
|
+
* word-selection background.
|
|
86
|
+
*/
|
|
87
|
+
const cursorStore = {
|
|
88
|
+
value: { line: 1, column: 1 },
|
|
89
|
+
listeners: new Set(),
|
|
90
|
+
frame: 0,
|
|
91
|
+
pending: null,
|
|
92
|
+
set(position) {
|
|
93
|
+
if (!position) return
|
|
94
|
+
const next = { line: Number(position.line) || 1, column: Number(position.column) || 1 }
|
|
95
|
+
if (next.line === this.value.line && next.column === this.value.column) return
|
|
96
|
+
this.pending = next
|
|
97
|
+
if (this.frame) return
|
|
98
|
+
const flush = () => {
|
|
99
|
+
this.frame = 0
|
|
100
|
+
const value = this.pending
|
|
101
|
+
this.pending = null
|
|
102
|
+
if (!value) return
|
|
103
|
+
this.value = value
|
|
104
|
+
for (const listener of this.listeners) {
|
|
105
|
+
try { listener() } catch (error) { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try { this.frame = requestAnimationFrame(flush) } catch (error) { flush() }
|
|
109
|
+
},
|
|
110
|
+
reset() { this.value = { line: 1, column: 1 } },
|
|
111
|
+
subscribe(listener) {
|
|
112
|
+
this.listeners.add(listener)
|
|
113
|
+
return () => this.listeners.delete(listener)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Subscribe one component to the cursor store. */
|
|
118
|
+
function useCursorPosition() {
|
|
119
|
+
const [value, setValue] = React.useState(cursorStore.value)
|
|
120
|
+
React.useEffect(() => cursorStore.subscribe(() => setValue(cursorStore.value)), [])
|
|
121
|
+
return value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Text width helper: ellipsis in the middle so file extensions stay visible.
|
|
126
|
+
* @param {string} text source text
|
|
127
|
+
* @param {number} max maximum characters
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function ellipsisMiddle(text, max) {
|
|
131
|
+
const value = String(text || '')
|
|
132
|
+
if (value.length <= max) return value
|
|
133
|
+
const half = Math.max(1, Math.floor((max - 1) / 2))
|
|
134
|
+
return value.slice(0, half) + '…' + value.slice(value.length - (max - half - 1))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Copy text to the clipboard with a legacy textarea fallback.
|
|
139
|
+
* @param {string} text text to copy
|
|
140
|
+
* @returns {Promise<boolean>}
|
|
141
|
+
*/
|
|
142
|
+
async function copyText(text) {
|
|
143
|
+
try {
|
|
144
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
145
|
+
await navigator.clipboard.writeText(text)
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
} catch (error) { /* fall through */ }
|
|
149
|
+
try {
|
|
150
|
+
const area = document.createElement('textarea')
|
|
151
|
+
area.value = text
|
|
152
|
+
area.style.position = 'fixed'
|
|
153
|
+
area.style.opacity = '0'
|
|
154
|
+
document.body.appendChild(area)
|
|
155
|
+
area.select()
|
|
156
|
+
const ok = document.execCommand('copy')
|
|
157
|
+
document.body.removeChild(area)
|
|
158
|
+
return ok
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Map a path recorded by workspace-changes (absolute host path or workspace
|
|
166
|
+
* relative, either separator) onto a workspace-relative posix path. The TURN
|
|
167
|
+
* recorder may record absolute paths for file-tool edits, while the review
|
|
168
|
+
* routes and tree use cwd-relative paths.
|
|
169
|
+
* @param {string} rawPath recorded path
|
|
170
|
+
* @param {string | undefined} cwd session working directory
|
|
171
|
+
* @returns {string} workspace-relative posix path (unchanged when not under cwd)
|
|
172
|
+
*/
|
|
173
|
+
function toWorkspaceRel(rawPath, cwd) {
|
|
174
|
+
const value = normalizeRel(rawPath)
|
|
175
|
+
if (!value) return ''
|
|
176
|
+
const absolute = /^[\\/]/.test(String(rawPath)) || /^[a-zA-Z]:[\\/]/.test(String(rawPath))
|
|
177
|
+
const rootPath = String(cwd || '').replace(/\\/g, '/')
|
|
178
|
+
const windows = /^[a-zA-Z]:\//.test(rootPath) || /^\/\/[^/]+\/[^/]+/.test(rootPath)
|
|
179
|
+
const root = normalizeRel(rootPath).replace(/\/+$/, '')
|
|
180
|
+
if (root && absolute) {
|
|
181
|
+
const comparedValue = windows ? value.toLowerCase() : value
|
|
182
|
+
const comparedRoot = windows ? root.toLowerCase() : root
|
|
183
|
+
if (comparedValue === comparedRoot) return ''
|
|
184
|
+
if (comparedValue.startsWith(comparedRoot + '/')) return value.slice(root.length + 1)
|
|
185
|
+
}
|
|
186
|
+
return value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The right-sidebar / builtin preview resource address for one workspace file.
|
|
191
|
+
* @param {string} sessionId session identity
|
|
192
|
+
* @param {string} rel workspace-relative path * @returns {string} `dsh-resource://file/session/…` address
|
|
193
|
+
*/
|
|
194
|
+
function fileAddressOf(sessionId, rel) {
|
|
195
|
+
const segments = segmentsOf(rel).map((segment) => encodeURIComponent(segment))
|
|
196
|
+
return FILE_ADDRESS_PREFIX + encodeURIComponent(sessionId) + '/' + segments.join('/')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The builtin hunk-review resource address for one turn.
|
|
201
|
+
* @param {string} sessionId session identity
|
|
202
|
+
* @param {number} seq `workspace/changes` event sequence
|
|
203
|
+
* @param {number} turn turn number
|
|
204
|
+
* @returns {string} `dsh-resource://changes-review/session/…` address
|
|
205
|
+
*/
|
|
206
|
+
function changesReviewAddressOf(sessionId, seq, turn) {
|
|
207
|
+
return CHANGES_REVIEW_PREFIX + encodeURIComponent(sessionId) + '/' + String(seq) + '/' + String(turn)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Call `openResource` defensively; returns false instead of throwing.
|
|
212
|
+
* @param {any} right sidebarRight controller
|
|
213
|
+
* @param {string} address resource address
|
|
214
|
+
* @param {object} [options] navigation options
|
|
215
|
+
* @returns {boolean}
|
|
216
|
+
*/
|
|
217
|
+
function openResourceSafe(right, address, options) {
|
|
218
|
+
if (!right || typeof right.openResource !== 'function') return false
|
|
219
|
+
try {
|
|
220
|
+
right.openResource(address, options || undefined)
|
|
221
|
+
return true
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log('warn', 'openResource failed', address, error)
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Small icon button.
|
|
230
|
+
* @param {object} props button props
|
|
231
|
+
* @returns {any} React element
|
|
232
|
+
*/
|
|
233
|
+
function IconButton(props) {
|
|
234
|
+
const { title, label, onClick, active, disabled, className } = props
|
|
235
|
+
return h('button', {
|
|
236
|
+
type: 'button',
|
|
237
|
+
className: cx('drp-icon-btn', active && 'drp-icon-btn-on', className),
|
|
238
|
+
title,
|
|
239
|
+
'aria-label': title,
|
|
240
|
+
disabled: disabled === true,
|
|
241
|
+
onClick: disabled === true ? undefined : onClick,
|
|
242
|
+
}, label)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Non-code / binary / oversized handoff card: never a blank page.
|
|
247
|
+
* @param {object} props component props
|
|
248
|
+
* @returns {any} React element
|
|
249
|
+
*/
|
|
250
|
+
function HandoffCard(props) {
|
|
251
|
+
const { sessionId, path, kind, t, right, absolutePath, bytes, onCopy, note } = props
|
|
252
|
+
const name = basename(path)
|
|
253
|
+
const isCodeButBlocked = kind === 'binary' || kind === 'oversized'
|
|
254
|
+
const [feedback, setFeedback] = React.useState('')
|
|
255
|
+
/** Open the file through the builtin DSH preview. */
|
|
256
|
+
const openPreview = () => {
|
|
257
|
+
const ok = openResourceSafe(right, fileAddressOf(sessionId, path))
|
|
258
|
+
if (!ok) {
|
|
259
|
+
setFeedback(t('error.openFailed', { message: 'preview' }))
|
|
260
|
+
void copyText(absolutePath || path)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Copy the absolute (or relative) location. */
|
|
264
|
+
const copy = async () => {
|
|
265
|
+
const ok = await copyText(absolutePath || path)
|
|
266
|
+
setFeedback(ok ? t('common.copied') : '')
|
|
267
|
+
onCopy?.()
|
|
268
|
+
}
|
|
269
|
+
return h(
|
|
270
|
+
'div',
|
|
271
|
+
{ className: 'drp-handoff' },
|
|
272
|
+
h('div', { className: 'drp-handoff-icon' }, isCodeButBlocked ? '⚠' : '¶'),
|
|
273
|
+
h('div', { className: 'drp-handoff-title' }, isCodeButBlocked
|
|
274
|
+
? (kind === 'binary' ? t('status.binary') : t('status.oversized', { size: fmtInt(Math.round((bytes || 0) / 1024)) + ' KiB' }))
|
|
275
|
+
: t('handoff.title')),
|
|
276
|
+
h('div', { className: 'drp-handoff-path' }, absolutePath || path),
|
|
277
|
+
h('div', { className: 'drp-handoff-body' }, isCodeButBlocked
|
|
278
|
+
? t('handoff.body', { name })
|
|
279
|
+
: t('handoff.body', { name })),
|
|
280
|
+
h(
|
|
281
|
+
'div',
|
|
282
|
+
{ className: 'drp-handoff-actions' },
|
|
283
|
+
h('button', { type: 'button', className: 'drp-btn drp-btn-primary', onClick: openPreview }, t('action.openInPreview')),
|
|
284
|
+
h('button', { type: 'button', className: 'drp-btn', onClick: () => { void copy() } }, t('action.copyPath')),
|
|
285
|
+
),
|
|
286
|
+
feedback ? h('div', { className: 'drp-handoff-note' }, feedback) : null,
|
|
287
|
+
note ? h('div', { className: 'drp-handoff-note' }, note) : null,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Empty placeholder shown before a file is selected.
|
|
293
|
+
* @param {object} props component props
|
|
294
|
+
* @returns {any} React element
|
|
295
|
+
*/
|
|
296
|
+
function EmptyState(props) {
|
|
297
|
+
const { t, latest } = props
|
|
298
|
+
return h(
|
|
299
|
+
'div',
|
|
300
|
+
{ className: 'drp-empty' },
|
|
301
|
+
h('div', { className: 'drp-empty-icon' }, '⌥'),
|
|
302
|
+
h('div', { className: 'drp-empty-title' }, t('empty.title')),
|
|
303
|
+
h('div', { className: 'drp-empty-body' }, t('empty.body')),
|
|
304
|
+
latest && latest.available && latest.summary
|
|
305
|
+
? h('div', { className: 'drp-empty-hint' }, t('hint.turnMulti', { count: String(latest.summary.files.length) }))
|
|
306
|
+
: null,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The editor status bar. Cursor moves are the only high-frequency update in the
|
|
312
|
+
* page, so this component subscribes to the cursor store itself and React never
|
|
313
|
+
* re-renders the tree / tab strip for a click.
|
|
314
|
+
* @param {object} props component props
|
|
315
|
+
* @returns {any} React element
|
|
316
|
+
*/
|
|
317
|
+
function ReviewStatusBar(props) {
|
|
318
|
+
const { statusText, file, gitText, t, narrow } = props
|
|
319
|
+
const cursor = useCursorPosition()
|
|
320
|
+
const fileReady = file && file.status === 'ready'
|
|
321
|
+
return h(
|
|
322
|
+
'div',
|
|
323
|
+
{ className: 'drp-status' },
|
|
324
|
+
h('span', { className: 'drp-status-text', title: statusText }, statusText),
|
|
325
|
+
h('span', { className: 'drp-spacer' }),
|
|
326
|
+
fileReady && !narrow ? h('span', {}, t('status.fileInfo', { lines: fmtInt(file.lines), language: file.language })) : null,
|
|
327
|
+
gitText ? h('span', {}, gitText) : null,
|
|
328
|
+
h('span', {}, t('status.lines', { line: String(cursor.line), column: String(cursor.column) })),
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Empty hunk counters (stable identity so re-applying it skips re-renders). */
|
|
333
|
+
const EMPTY_HUNK_META = { total: 0, added: 0, deleted: 0 }
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Normalize one `/file` route payload into the page's file record.
|
|
337
|
+
* @param {string} path workspace-relative path
|
|
338
|
+
* @param {any} value host response
|
|
339
|
+
* @returns {object} file record
|
|
340
|
+
*/
|
|
341
|
+
function fileRecordOf(path, value) {
|
|
342
|
+
if (value && value.kind === 'text') {
|
|
343
|
+
return {
|
|
344
|
+
status: 'ready',
|
|
345
|
+
path,
|
|
346
|
+
kind: 'text',
|
|
347
|
+
text: value.text || '',
|
|
348
|
+
lines: Number(value.lines) || splitLines(value.text || '').length,
|
|
349
|
+
language: languageOf(path),
|
|
350
|
+
version: value.version,
|
|
351
|
+
absolutePath: value.absolutePath,
|
|
352
|
+
bytes: value.bytes,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!value || !value.kind) {
|
|
356
|
+
return { status: 'handoff', path, kind: 'external', absolutePath: value && value.absolutePath, bytes: value && value.bytes }
|
|
357
|
+
}
|
|
358
|
+
return { status: 'handoff', path, kind: value.kind, absolutePath: value.absolutePath, bytes: value.bytes }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The code review page.
|
|
363
|
+
* @param {object} props component props (`sessionId`, `t`, `useSessions`, plus services)
|
|
364
|
+
* @returns {any} React element
|
|
365
|
+
*/
|
|
366
|
+
function ReviewPage(props) {
|
|
367
|
+
const { sessionId, t, useSessions, useTabInfo, services, address } = props
|
|
368
|
+
const right = services && services.right
|
|
369
|
+
const rootRef = React.useRef(null)
|
|
370
|
+
const abortRef = React.useRef(null)
|
|
371
|
+
const prefetchAbortRef = React.useRef(null)
|
|
372
|
+
const fileTokenRef = React.useRef(0)
|
|
373
|
+
const latestTokenRef = React.useRef(0)
|
|
374
|
+
const revisionTokenRef = React.useRef(0)
|
|
375
|
+
const historyRef = React.useRef({ past: [], future: [] })
|
|
376
|
+
const handlersRef = React.useRef({})
|
|
377
|
+
const initialisedRef = React.useRef(false)
|
|
378
|
+
const autoOpenedRef = React.useRef(false)
|
|
379
|
+
const sessionRef = React.useRef(sessionId)
|
|
380
|
+
/** Latest render snapshot of the state that must survive this body's unmount. */
|
|
381
|
+
const pageSnapshotRef = React.useRef(null)
|
|
382
|
+
/** One-shot flag: the restored active file still has to be read from the Host. */
|
|
383
|
+
const restoreLoadRef = React.useRef(false)
|
|
384
|
+
/** Snapshot-effect bookkeeping: the last (snapshotKey, path) it force-loaded. */
|
|
385
|
+
const lastSnapshotLoadRef = React.useRef(null)
|
|
386
|
+
const editorControlsRef = React.useRef(null)
|
|
387
|
+
const fontSizeRef = React.useRef(13)
|
|
388
|
+
const gitStatusRef = React.useRef(null)
|
|
389
|
+
const pendingRevealRef = React.useRef(null)
|
|
390
|
+
/** Per-path file payload cache: a revisit renders from memory, not a round trip. */
|
|
391
|
+
const fileCacheRef = React.useRef(new Map())
|
|
392
|
+
const cacheGenerationRef = React.useRef(0)
|
|
393
|
+
/** Per (baseline, path) comparison cache: diff rendering is instant on revisit. */
|
|
394
|
+
const diffCacheRef = React.useRef(new Map())
|
|
395
|
+
/** Files whose first diff was already auto-revealed (revisit keeps scroll). */
|
|
396
|
+
const revealOnceRef = React.useRef(new Set())
|
|
397
|
+
|
|
398
|
+
const cwdFromStore = useSessions
|
|
399
|
+
? useSessions((sessions) => (sessions && sessions.byId && sessionId && sessions.byId[sessionId] ? sessions.byId[sessionId].cwd : undefined))
|
|
400
|
+
: undefined
|
|
401
|
+
const running = useSessions
|
|
402
|
+
? useSessions((sessions) => (sessions && sessions.byId && sessionId && sessions.byId[sessionId] ? sessions.byId[sessionId].running : undefined))
|
|
403
|
+
: undefined
|
|
404
|
+
|
|
405
|
+
const [monaco, setMonaco] = React.useState(null)
|
|
406
|
+
const [monacoError, setMonacoError] = React.useState(null)
|
|
407
|
+
const [latest, setLatest] = React.useState(null)
|
|
408
|
+
const [latestLoading, setLatestLoading] = React.useState(true)
|
|
409
|
+
const [mode, setMode] = React.useState('code')
|
|
410
|
+
const [modeTouched, setModeTouched] = React.useState(false)
|
|
411
|
+
const [openTabs, setOpenTabs] = React.useState([])
|
|
412
|
+
const [activePath, setActivePath] = React.useState(null)
|
|
413
|
+
const [file, setFile] = React.useState(null)
|
|
414
|
+
const [comparison, setComparison] = React.useState(null)
|
|
415
|
+
const [regions, setRegions] = React.useState([])
|
|
416
|
+
const [hunkMeta, setHunkMeta] = React.useState({ total: 0, added: 0, deleted: 0 })
|
|
417
|
+
const [hunkIndex, setHunkIndex] = React.useState(0)
|
|
418
|
+
const [reveal, setReveal] = React.useState({ line: 0, nonce: 0 })
|
|
419
|
+
const [treeOpen, setTreeOpen] = React.useState(true)
|
|
420
|
+
const [treeWidth, setTreeWidth] = React.useState(TREE_WIDTH_DEFAULT)
|
|
421
|
+
const [narrow, setNarrow] = React.useState(false)
|
|
422
|
+
const [status, setStatus] = React.useState(null)
|
|
423
|
+
const [scheme, setScheme] = React.useState(themeRuntime.scheme)
|
|
424
|
+
const [fontSize, setFontSize] = React.useState(() => {
|
|
425
|
+
try {
|
|
426
|
+
const stored = Number(localStorage.getItem('dsh-git-changes.fontSize'))
|
|
427
|
+
return Number.isFinite(stored) && stored >= 9 && stored <= 28 ? Math.round(stored) : 13
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return 13
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
const [menuOpen, setMenuOpen] = React.useState(false)
|
|
433
|
+
const [gitStatus, setGitStatus] = React.useState(null)
|
|
434
|
+
/** Right-hand panel surface: Git CHANGES (default) or the workspace tree. */
|
|
435
|
+
const [panelMode, setPanelMode] = React.useState(() => {
|
|
436
|
+
try {
|
|
437
|
+
return localStorage.getItem('dsh-git-changes.panelMode') === 'tree' ? 'tree' : 'changes'
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return 'changes'
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
/** Which diff baseline the CHANGES panel opened ("staged" = HEAD↔index). */
|
|
443
|
+
const [changeScope, setChangeScope] = React.useState(() => storedChangeScope())
|
|
444
|
+
/** Diff presentation: auto (width decides), inline (VS Code inline view) or side. */
|
|
445
|
+
const [diffLayout, setDiffLayout] = React.useState(() => {
|
|
446
|
+
try {
|
|
447
|
+
const stored = localStorage.getItem('dsh-git-changes.diffLayout')
|
|
448
|
+
return stored === 'inline' || stored === 'side' ? stored : 'auto'
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return 'auto'
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
/** Latest per-line blame rows for the active file (shown on cursor dwell). */
|
|
454
|
+
const [blameLines, setBlameLines] = React.useState([])
|
|
455
|
+
const [revision, setRevision] = React.useState(null)
|
|
456
|
+
const [revisionData, setRevisionData] = React.useState(null)
|
|
457
|
+
const [revisionLoading, setRevisionLoading] = React.useState(false)
|
|
458
|
+
const [revisionRefreshToken, setRevisionRefreshToken] = React.useState(0)
|
|
459
|
+
// Bumped when the shown snapshot kind changes (worktree ↔ historical commit):
|
|
460
|
+
// Monaco keeps the same model/text, so the Host half needs an explicit
|
|
461
|
+
// re-announce to hand the language server the right document.
|
|
462
|
+
const [documentEpoch, setDocumentEpoch] = React.useState(0)
|
|
463
|
+
const [picker, setPicker] = React.useState(null)
|
|
464
|
+
const [treeRefreshToken, setTreeRefreshToken] = React.useState(0)
|
|
465
|
+
const [lspMissing, setLspMissing] = React.useState(null)
|
|
466
|
+
|
|
467
|
+
const treeVisible = treeOpen && !narrow
|
|
468
|
+
|
|
469
|
+
// Remember which panel surface the user prefers (VS Code persists this too).
|
|
470
|
+
React.useEffect(() => {
|
|
471
|
+
try {
|
|
472
|
+
localStorage.setItem('dsh-git-changes.panelMode', panelMode)
|
|
473
|
+
} catch (error) { /* storage unavailable */ }
|
|
474
|
+
}, [panelMode])
|
|
475
|
+
|
|
476
|
+
React.useEffect(() => {
|
|
477
|
+
try {
|
|
478
|
+
localStorage.setItem('dsh-git-changes.diffLayout', diffLayout)
|
|
479
|
+
} catch (error) { /* storage unavailable */ }
|
|
480
|
+
}, [diffLayout])
|
|
481
|
+
|
|
482
|
+
React.useEffect(() => {
|
|
483
|
+
try {
|
|
484
|
+
localStorage.setItem('dsh-git-changes.mode', mode)
|
|
485
|
+
} catch (error) { /* storage unavailable */ }
|
|
486
|
+
}, [mode])
|
|
487
|
+
|
|
488
|
+
React.useEffect(() => {
|
|
489
|
+
try {
|
|
490
|
+
localStorage.setItem('dsh-git-changes.changeScope', changeScope)
|
|
491
|
+
} catch (error) { /* storage unavailable */ }
|
|
492
|
+
}, [changeScope])
|
|
493
|
+
|
|
494
|
+
// Blame follows the visible file: switching files or the snapshot refetches.
|
|
495
|
+
// The rows are only rendered on demand when the cursor rests on a line.
|
|
496
|
+
React.useEffect(() => {
|
|
497
|
+
if (!activePath || !sessionId) {
|
|
498
|
+
setBlameLines([])
|
|
499
|
+
return undefined
|
|
500
|
+
}
|
|
501
|
+
const controller = new AbortController()
|
|
502
|
+
let live = true
|
|
503
|
+
apiGet('blame', { sessionId, path: activePath }, controller.signal)
|
|
504
|
+
.then((value) => {
|
|
505
|
+
if (!live) return
|
|
506
|
+
const rows = value && value.available === true && Array.isArray(value.lines) ? value.lines : []
|
|
507
|
+
setBlameLines(rows)
|
|
508
|
+
})
|
|
509
|
+
.catch((error) => {
|
|
510
|
+
if (live) setBlameLines([])
|
|
511
|
+
})
|
|
512
|
+
return () => {
|
|
513
|
+
live = false
|
|
514
|
+
controller.abort()
|
|
515
|
+
}
|
|
516
|
+
}, [activePath, sessionId, documentEpoch])
|
|
517
|
+
const summary = latest && latest.available ? latest.summary : null
|
|
518
|
+
// Revision mode pins every cache key to one (commit, scope) pair, so switching
|
|
519
|
+
// versions never paints another version's text or hunks.
|
|
520
|
+
const revisionKey = mode === 'revision' && revision ? 'rev:' + revision.sha + ':' + revision.scope : ''
|
|
521
|
+
const revisionSha = revision ? revision.sha : ''
|
|
522
|
+
const revisionScope = revision ? revision.scope : ''
|
|
523
|
+
// Content and comparisons belong to the same announcement. During the
|
|
524
|
+
// render before the refresh effect runs, the old text must not accept the
|
|
525
|
+
// next snapshot's hunks.
|
|
526
|
+
const snapshotKey = String(sessionId || '') + ':' + (revisionKey || (latest && latest.available ? String(latest.seq) : 'none'))
|
|
527
|
+
const fileCurrent = file && file.path === activePath && file.snapshotKey === snapshotKey
|
|
528
|
+
// Historical (= immutable commit content) mode: the diff is base → commit, and
|
|
529
|
+
// the language server must not see the old document (line numbers of the
|
|
530
|
+
// worktree no longer match).
|
|
531
|
+
const historical = mode === 'revision' && Boolean(revision) && revision.scope === 'commit'
|
|
532
|
+
|
|
533
|
+
// Render-synchronous snapshot of everything worth restoring after this tab
|
|
534
|
+
// body unmounts (session switch, tab switch, tab close).
|
|
535
|
+
sessionRef.current = sessionId
|
|
536
|
+
pageSnapshotRef.current = {
|
|
537
|
+
openTabs: openTabs.slice(),
|
|
538
|
+
activePath,
|
|
539
|
+
mode,
|
|
540
|
+
modeTouched,
|
|
541
|
+
revision: revision ? { ...revision } : null,
|
|
542
|
+
treeOpen,
|
|
543
|
+
treeWidth,
|
|
544
|
+
initialised: initialisedRef.current,
|
|
545
|
+
autoOpened: autoOpenedRef.current,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Publish a transient status line. */
|
|
549
|
+
const notify = React.useCallback((text, kind) => {
|
|
550
|
+
if (!text) return
|
|
551
|
+
setStatus({ text: String(text), kind: kind || 'info', at: Date.now() })
|
|
552
|
+
}, [])
|
|
553
|
+
|
|
554
|
+
// Transient status auto-clear.
|
|
555
|
+
React.useEffect(() => {
|
|
556
|
+
if (!status) return undefined
|
|
557
|
+
const timer = setTimeout(() => setStatus(null), 4200)
|
|
558
|
+
return () => clearTimeout(timer)
|
|
559
|
+
}, [status])
|
|
560
|
+
|
|
561
|
+
// Page width measurement for the narrow layout. Only the boolean crosses the
|
|
562
|
+
// React state boundary: during a DSH splitter drag the sidebar width changes
|
|
563
|
+
// on every frame, and keeping pixel width in state re-rendered the whole page
|
|
564
|
+
// (tree + editor) per frame — the reported divider-drag stutter.
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
const node = rootRef.current
|
|
567
|
+
if (!node) return undefined
|
|
568
|
+
const measure = () => {
|
|
569
|
+
try {
|
|
570
|
+
const next = (node.getBoundingClientRect().width || node.clientWidth || 900) < NARROW_WIDTH
|
|
571
|
+
setNarrow((previous) => (previous === next ? previous : next))
|
|
572
|
+
} catch (error) { /* ignore */ }
|
|
573
|
+
}
|
|
574
|
+
measure()
|
|
575
|
+
let observer
|
|
576
|
+
try {
|
|
577
|
+
observer = new ResizeObserver(measure)
|
|
578
|
+
observer.observe(node)
|
|
579
|
+
} catch (error) { /* ResizeObserver unavailable: keep the default */ }
|
|
580
|
+
window.addEventListener('resize', measure)
|
|
581
|
+
return () => {
|
|
582
|
+
try { observer?.disconnect() } catch (error) { /* ignore */ }
|
|
583
|
+
window.removeEventListener('resize', measure)
|
|
584
|
+
}
|
|
585
|
+
}, [])
|
|
586
|
+
|
|
587
|
+
// Monaco loads lazily when this page first mounts.
|
|
588
|
+
React.useEffect(() => {
|
|
589
|
+
let cancelled = false
|
|
590
|
+
loadMonaco()
|
|
591
|
+
.then((namespace) => {
|
|
592
|
+
if (cancelled) return
|
|
593
|
+
setMonaco(namespace)
|
|
594
|
+
const applied = applyMonacoTheme(namespace)
|
|
595
|
+
setScheme(applied)
|
|
596
|
+
})
|
|
597
|
+
.catch((error) => {
|
|
598
|
+
if (cancelled) return
|
|
599
|
+
setMonacoError(error)
|
|
600
|
+
log('error', 'monaco failed to load', error)
|
|
601
|
+
})
|
|
602
|
+
return () => { cancelled = true }
|
|
603
|
+
}, [])
|
|
604
|
+
|
|
605
|
+
// Theme changes repaint the editor and diff decorations.
|
|
606
|
+
React.useEffect(() => {
|
|
607
|
+
const dispose = observeTheme(globalCtxRef.current, () => {
|
|
608
|
+
const next = applyMonacoTheme(monaco)
|
|
609
|
+
setScheme(next)
|
|
610
|
+
})
|
|
611
|
+
return dispose
|
|
612
|
+
}, [monaco])
|
|
613
|
+
|
|
614
|
+
/** Reload the latest turn announcement for this session. */
|
|
615
|
+
const modeRef = React.useRef(mode)
|
|
616
|
+
modeRef.current = mode
|
|
617
|
+
const tRef = React.useRef(t)
|
|
618
|
+
tRef.current = t
|
|
619
|
+
const notifyRef = React.useRef(notify)
|
|
620
|
+
notifyRef.current = notify
|
|
621
|
+
const refreshLatest = React.useCallback(async () => {
|
|
622
|
+
if (!sessionId) return
|
|
623
|
+
const token = ++latestTokenRef.current
|
|
624
|
+
setLatestLoading(true)
|
|
625
|
+
try {
|
|
626
|
+
const value = await apiGet('latest', { sessionId })
|
|
627
|
+
if (token !== latestTokenRef.current) return
|
|
628
|
+
// Keep the previous object when the snapshot did not change: a fresh
|
|
629
|
+
// object identity would re-run the diff effect and repaint the editor
|
|
630
|
+
// (the old code refreshed on every window focus and looked laggy).
|
|
631
|
+
setLatest((previous) => {
|
|
632
|
+
const next = { ...(value || {}), loaded: true }
|
|
633
|
+
if (previous && previous.loaded === true
|
|
634
|
+
&& previous.available === next.available
|
|
635
|
+
&& previous.seq === next.seq
|
|
636
|
+
&& previous.turn === next.turn) return previous
|
|
637
|
+
return next
|
|
638
|
+
})
|
|
639
|
+
if (modeRef.current === 'turn' && value && value.available === false) {
|
|
640
|
+
const fallback = gitStatusRef.current?.available ? 'git' : 'code'
|
|
641
|
+
setMode(fallback)
|
|
642
|
+
setModeTouched(true)
|
|
643
|
+
notifyRef.current(tRef.current('mode.turn.unavailable'), 'warn')
|
|
644
|
+
}
|
|
645
|
+
} catch (failure) {
|
|
646
|
+
if (token !== latestTokenRef.current) return
|
|
647
|
+
setLatest((previous) => (previous && previous.loaded === true ? previous : { available: false, loaded: true, reason: 'request-failed' }))
|
|
648
|
+
} finally {
|
|
649
|
+
if (token === latestTokenRef.current) setLatestLoading(false)
|
|
650
|
+
}
|
|
651
|
+
}, [sessionId])
|
|
652
|
+
|
|
653
|
+
/** Refresh the git working-tree status used by the Git mode and tree badges. */
|
|
654
|
+
const loadGitStatus = React.useCallback(async () => {
|
|
655
|
+
if (!sessionId) return
|
|
656
|
+
try {
|
|
657
|
+
const value = await apiGet('gitStatus', { sessionId })
|
|
658
|
+
gitStatusRef.current = value || { available: false }
|
|
659
|
+
setGitStatus(value || { available: false })
|
|
660
|
+
} catch (failure) {
|
|
661
|
+
const value = { available: false, reason: 'request-failed' }
|
|
662
|
+
gitStatusRef.current = value
|
|
663
|
+
setGitStatus(value)
|
|
664
|
+
}
|
|
665
|
+
}, [sessionId])
|
|
666
|
+
|
|
667
|
+
// Probe git once when the page mounts (also decides the initial baseline).
|
|
668
|
+
React.useEffect(() => {
|
|
669
|
+
void loadGitStatus()
|
|
670
|
+
}, [loadGitStatus])
|
|
671
|
+
|
|
672
|
+
// Initial baseline: restore this session's remembered workspace (tabs, active
|
|
673
|
+
// file, diff baseline, git version) or fall back to TURN when the Host recorded
|
|
674
|
+
// a turn, else GIT when the workspace is a repository, else code-only. The
|
|
675
|
+
// right-sidebar tab body is remounted whenever the user switches sessions, so
|
|
676
|
+
// this is the only place the previous visit's state comes back.
|
|
677
|
+
React.useEffect(() => {
|
|
678
|
+
if (initialisedRef.current) return
|
|
679
|
+
if (!latest || latest.loaded !== true) return
|
|
680
|
+
if (gitStatus === null) return
|
|
681
|
+
initialisedRef.current = true
|
|
682
|
+
const gitAvailable = gitStatus.available === true
|
|
683
|
+
const defaultMode = () => (gitAvailable ? 'git' : latest.available ? 'turn' : 'code')
|
|
684
|
+
// The Host probes can be slow: the user may already have opened a file or
|
|
685
|
+
// picked a baseline during the remount window. That choice wins over the
|
|
686
|
+
// remembered snapshot (which was captured before this mount).
|
|
687
|
+
const interacted = Boolean(activePath) || openTabs.length > 0 || revision !== null || modeTouched === true || mode !== 'code'
|
|
688
|
+
if (interacted) {
|
|
689
|
+
if (!modeTouched) setMode(defaultMode())
|
|
690
|
+
// Never let the turn's auto-open replace the file the user just picked.
|
|
691
|
+
autoOpenedRef.current = true
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
const saved = rememberedPageState(sessionId)
|
|
695
|
+
if (!saved) {
|
|
696
|
+
// First mount in this app run: honour the baseline the user left behind in
|
|
697
|
+
// a previous run so diff mode (and its hunk navigation/colors) comes back.
|
|
698
|
+
const rememberedMode = storedBaseline()
|
|
699
|
+
const nextMode = rememberedMode === 'git' && gitAvailable
|
|
700
|
+
? 'git'
|
|
701
|
+
: rememberedMode === 'turn' && latest.available
|
|
702
|
+
? 'turn'
|
|
703
|
+
: defaultMode()
|
|
704
|
+
setMode(nextMode)
|
|
705
|
+
if (nextMode !== defaultMode()) setModeTouched(true)
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
const modes = ['turn', 'git', 'revision', 'code']
|
|
709
|
+
let nextMode = modes.includes(saved.mode) ? saved.mode : ''
|
|
710
|
+
if (!nextMode) nextMode = defaultMode()
|
|
711
|
+
if (nextMode === 'turn' && !latest.available) nextMode = gitAvailable ? 'git' : 'code'
|
|
712
|
+
if (nextMode === 'git' && !gitAvailable) nextMode = 'code'
|
|
713
|
+
let nextRevision = null
|
|
714
|
+
if (nextMode === 'revision') {
|
|
715
|
+
const record = saved.revision
|
|
716
|
+
if (gitAvailable && record && typeof record.sha === 'string' && record.sha !== '') {
|
|
717
|
+
nextRevision = {
|
|
718
|
+
sha: record.sha,
|
|
719
|
+
short: typeof record.short === 'string' && record.short !== '' ? record.short : record.sha,
|
|
720
|
+
subject: typeof record.subject === 'string' ? record.subject : '',
|
|
721
|
+
date: typeof record.date === 'string' ? record.date : undefined,
|
|
722
|
+
scope: record.scope === 'since' ? 'since' : 'commit',
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
// A git version belongs to a repository: never restore it without one.
|
|
726
|
+
nextMode = gitAvailable ? 'git' : 'code'
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const tabs = Array.isArray(saved.openTabs) ? saved.openTabs.filter((path) => typeof path === 'string' && path !== '') : []
|
|
730
|
+
const active = typeof saved.activePath === 'string' && saved.activePath !== '' ? saved.activePath : null
|
|
731
|
+
if (active && !tabs.includes(active)) tabs.push(active)
|
|
732
|
+
setOpenTabs(tabs)
|
|
733
|
+
setActivePath(active)
|
|
734
|
+
if (typeof saved.treeOpen === 'boolean') setTreeOpen(saved.treeOpen)
|
|
735
|
+
const width = Number(saved.treeWidth)
|
|
736
|
+
if (Number.isFinite(width)) setTreeWidth(Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width))))
|
|
737
|
+
autoOpenedRef.current = saved.autoOpened === true || Boolean(active)
|
|
738
|
+
// A restored file did not go through openPath(), so it still has to be
|
|
739
|
+
// read from the Host once the final baseline announcement is known.
|
|
740
|
+
restoreLoadRef.current = Boolean(active)
|
|
741
|
+
setRevision(nextRevision)
|
|
742
|
+
setMode(nextMode)
|
|
743
|
+
setModeTouched(saved.modeTouched === true)
|
|
744
|
+
}, [latest, gitStatus, sessionId])
|
|
745
|
+
|
|
746
|
+
// Persist the page's workspace state before this tab body unmounts. DSH
|
|
747
|
+
// unmounts the previous conversation's sidebar tab bodies on a session switch,
|
|
748
|
+
// so the next mount of the same session restores its tabs, active file and git
|
|
749
|
+
// version instead of starting from scratch.
|
|
750
|
+
React.useEffect(() => () => {
|
|
751
|
+
const state = pageSnapshotRef.current
|
|
752
|
+
if (!state) return
|
|
753
|
+
if (state.initialised !== true && state.openTabs.length === 0 && !state.activePath) return
|
|
754
|
+
rememberPageState(sessionRef.current, state)
|
|
755
|
+
}, [sessionId])
|
|
756
|
+
|
|
757
|
+
// Revision baseline: one compare payload drives the tree's changed-file list,
|
|
758
|
+
// the status bar and the per-file rev+scope comparisons below. refreshAll
|
|
759
|
+
// bumps revisionRefreshToken so this reloads.
|
|
760
|
+
React.useEffect(() => {
|
|
761
|
+
revisionTokenRef.current += 1
|
|
762
|
+
const token = revisionTokenRef.current
|
|
763
|
+
if (mode !== 'revision' || !revisionSha || !sessionId) {
|
|
764
|
+
setRevisionData(null)
|
|
765
|
+
setRevisionLoading(false)
|
|
766
|
+
return undefined
|
|
767
|
+
}
|
|
768
|
+
setRevisionData(null)
|
|
769
|
+
setRevisionLoading(true)
|
|
770
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
771
|
+
apiGet('gitCompare', { sessionId, rev: revisionSha, scope: revisionScope }, controller ? controller.signal : undefined)
|
|
772
|
+
.then((value) => {
|
|
773
|
+
if (token !== revisionTokenRef.current) return
|
|
774
|
+
setRevisionData(value || { available: false, reason: 'request-failed' })
|
|
775
|
+
})
|
|
776
|
+
.catch((failure) => {
|
|
777
|
+
if (token !== revisionTokenRef.current) return
|
|
778
|
+
setRevisionData({ available: false, reason: 'request-failed', message: failure && failure.message })
|
|
779
|
+
})
|
|
780
|
+
.finally(() => {
|
|
781
|
+
if (token === revisionTokenRef.current) setRevisionLoading(false)
|
|
782
|
+
})
|
|
783
|
+
return () => {
|
|
784
|
+
revisionTokenRef.current += 1
|
|
785
|
+
try { controller?.abort() } catch (error) { /* ignore */ }
|
|
786
|
+
}
|
|
787
|
+
}, [mode, sessionId, revisionSha, revisionScope, revisionRefreshToken])
|
|
788
|
+
|
|
789
|
+
/** Cache key for one comparison (turn snapshot, git baseline or git revision). */
|
|
790
|
+
const diffCacheKey = React.useCallback((path) => {
|
|
791
|
+
if (!path) return ''
|
|
792
|
+
if (mode === 'turn') return latest && latest.available ? 'turn:' + String(latest.seq) + ':' + path : ''
|
|
793
|
+
if (mode === 'git') return 'git:' + changeScope + ':' + (gitStatus && gitStatus.available === true ? '1' : '0') + ':' + path
|
|
794
|
+
if (mode === 'revision') return revisionSha ? 'rev:' + revisionSha + ':' + revisionScope + ':' + path : ''
|
|
795
|
+
return 'code:' + path
|
|
796
|
+
}, [mode, changeScope, latest && latest.seq, latest && latest.available, gitStatus && gitStatus.available, revisionSha, revisionScope])
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Apply one cached diff entry (or an error/empty marker) to the page state.
|
|
800
|
+
* The first diff of a file is revealed once; revisits keep the scroll position
|
|
801
|
+
* the user left, which is what makes switching back feel like VS Code.
|
|
802
|
+
* @param {object} entry cached diff entry
|
|
803
|
+
* @param {string} path active file
|
|
804
|
+
* @param {string} text current file text
|
|
805
|
+
*/
|
|
806
|
+
const applyDiffEntry = React.useCallback((entry, path, text) => {
|
|
807
|
+
if (!entry) return
|
|
808
|
+
const context = { path, snapshotKey, generation: cacheGenerationRef.current, key: diffCacheKey(path) }
|
|
809
|
+
if (entry.status === 'ready') {
|
|
810
|
+
const built = buildRegions(entry.value, text || '')
|
|
811
|
+
setComparison({ ...context, status: 'ready', value: entry.value, index: entry.index })
|
|
812
|
+
setRegions(built.regions)
|
|
813
|
+
setHunkMeta({ total: built.total, added: built.added, deleted: built.deleted })
|
|
814
|
+
setHunkIndex(0)
|
|
815
|
+
if (built.regions.length > 0 && !pendingRevealRef.current && !revealOnceRef.current.has(path)) {
|
|
816
|
+
revealOnceRef.current.add(path)
|
|
817
|
+
setReveal((previous) => ({ line: built.regions[0].start, nonce: previous.nonce + 1 }))
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
setRegions([])
|
|
822
|
+
setHunkMeta(EMPTY_HUNK_META)
|
|
823
|
+
setHunkIndex(0)
|
|
824
|
+
if (entry.status === 'deleted') {
|
|
825
|
+
setComparison({ ...context, status: 'deleted' })
|
|
826
|
+
notify(t('status.git.deleted'), 'warn')
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
setComparison({ ...context, status: entry.status, message: entry.message, kind: entry.kind })
|
|
830
|
+
}, [notify, t, snapshotKey, diffCacheKey])
|
|
831
|
+
|
|
832
|
+
// When this tab opens without a remembered baseline the session defaults to
|
|
833
|
+
// the turn comparison; if that comparison has nothing to show but the working
|
|
834
|
+
// tree does, fall forward to the git baseline so diff review is never blank.
|
|
835
|
+
React.useEffect(() => {
|
|
836
|
+
if (mode !== 'turn' || modeTouched) return
|
|
837
|
+
const status = gitStatusRef.current
|
|
838
|
+
if (!status || status.available !== true || !Array.isArray(status.files) || status.files.length === 0) return
|
|
839
|
+
if (!comparison || comparison.status !== 'ready' || regions.length > 0) return
|
|
840
|
+
setMode('git')
|
|
841
|
+
setModeTouched(true)
|
|
842
|
+
notify(t('mode.turn.emptyToGit'), 'info')
|
|
843
|
+
}, [mode, modeTouched, comparison, regions.length, notify, t])
|
|
844
|
+
|
|
845
|
+
/** Load one file's current content, using the per-path cache for instant switch. */
|
|
846
|
+
const loadFile = React.useCallback(async (path, options) => {
|
|
847
|
+
if (!sessionId || !path) return
|
|
848
|
+
const force = Boolean(options && options.force)
|
|
849
|
+
fileTokenRef.current += 1
|
|
850
|
+
const token = fileTokenRef.current
|
|
851
|
+
try { abortRef.current?.abort() } catch (error) { /* ignore */ }
|
|
852
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
853
|
+
abortRef.current = controller
|
|
854
|
+
cursorStore.reset()
|
|
855
|
+
if (!isCodeFile(path)) {
|
|
856
|
+
const handoff = { status: 'handoff', path, kind: 'external', snapshotKey }
|
|
857
|
+
fileCacheRef.current.set(path, handoff)
|
|
858
|
+
setFile(handoff)
|
|
859
|
+
setComparison({ status: 'none' })
|
|
860
|
+
setRegions([])
|
|
861
|
+
setHunkMeta(EMPTY_HUNK_META)
|
|
862
|
+
setHunkIndex(0)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
const candidate = force ? undefined : fileCacheRef.current.get(path)
|
|
866
|
+
const cached = candidate && candidate.snapshotKey === snapshotKey ? candidate : undefined
|
|
867
|
+
if (cached) setFile(cached)
|
|
868
|
+
else setFile({ status: 'loading', path, kind: 'text', snapshotKey })
|
|
869
|
+
// Re-apply a cached comparison synchronously; the diff effects only run for
|
|
870
|
+
// files whose comparison is not cached yet.
|
|
871
|
+
const cachedKey = force ? '' : diffCacheKey(path)
|
|
872
|
+
const cachedDiff = cachedKey ? diffCacheRef.current.get(cachedKey) : undefined
|
|
873
|
+
if (cachedDiff) applyDiffEntry(cachedDiff, path, cached && cached.status === 'ready' ? cached.text : '')
|
|
874
|
+
else {
|
|
875
|
+
setRegions([])
|
|
876
|
+
setHunkMeta(EMPTY_HUNK_META)
|
|
877
|
+
setHunkIndex(0)
|
|
878
|
+
setComparison(null)
|
|
879
|
+
}
|
|
880
|
+
if (cached) return
|
|
881
|
+
try {
|
|
882
|
+
// Historical content comes from git at the selected commit, never from the
|
|
883
|
+
// worktree; since-scope and the other baselines read the worktree file.
|
|
884
|
+
const params = historical && revision ? { sessionId, path, rev: revision.sha } : { sessionId, path }
|
|
885
|
+
const value = await apiGet('file', params, controller ? controller.signal : undefined)
|
|
886
|
+
if (token !== fileTokenRef.current) return
|
|
887
|
+
const record = { ...fileRecordOf(path, value), snapshotKey }
|
|
888
|
+
fileCacheRef.current.set(path, record)
|
|
889
|
+
setFile(record)
|
|
890
|
+
} catch (failure) {
|
|
891
|
+
if (token !== fileTokenRef.current) return
|
|
892
|
+
// A missing file is only a candidate deletion. It becomes an empty
|
|
893
|
+
// review model after the comparison confirms that its after side is
|
|
894
|
+
// absent. Permission/session/read errors remain ordinary errors.
|
|
895
|
+
const missing = failure && failure.status === 404 && failure.code === 'not-found'
|
|
896
|
+
const record = {
|
|
897
|
+
status: missing ? 'missing' : 'error', path, kind: 'text', snapshotKey,
|
|
898
|
+
text: '', lines: 1, language: languageOf(path), missing,
|
|
899
|
+
error: failure && failure.message ? failure.message : String(failure),
|
|
900
|
+
}
|
|
901
|
+
if (missing) fileCacheRef.current.set(path, record)
|
|
902
|
+
setFile(record)
|
|
903
|
+
}
|
|
904
|
+
}, [sessionId, snapshotKey, diffCacheKey, applyDiffEntry, historical, revision && revision.sha])
|
|
905
|
+
|
|
906
|
+
/** Match one active file against the turn summary's changed files. */
|
|
907
|
+
const changedIndexOf = React.useCallback((path) => {
|
|
908
|
+
if (!summary || !Array.isArray(summary.files) || !path) return -1
|
|
909
|
+
const target = normalizeRel(path)
|
|
910
|
+
const targetName = basename(target)
|
|
911
|
+
const relativeOfEntry = (fileEntry) => toWorkspaceRel(fileEntry.path || fileEntry.display || '', cwdFromStore)
|
|
912
|
+
for (let index = 0; index < summary.files.length; index += 1) {
|
|
913
|
+
const fileEntry = summary.files[index]
|
|
914
|
+
if (!fileEntry) continue
|
|
915
|
+
if (relativeOfEntry(fileEntry) === target) return index
|
|
916
|
+
}
|
|
917
|
+
// Fallback: unique basename match (workspace-changes may record paths
|
|
918
|
+
// relative to a repository above the session cwd, or absolute paths).
|
|
919
|
+
const candidates = []
|
|
920
|
+
for (let index = 0; index < summary.files.length; index += 1) {
|
|
921
|
+
const fileEntry = summary.files[index]
|
|
922
|
+
const rel = relativeOfEntry(fileEntry)
|
|
923
|
+
if (rel.endsWith('/' + target) || rel === target || basename(rel) === targetName) candidates.push(index)
|
|
924
|
+
}
|
|
925
|
+
return candidates.length === 1 ? candidates[0] : -1
|
|
926
|
+
}, [summary, cwdFromStore])
|
|
927
|
+
|
|
928
|
+
// Refresh the turn announcement on mount and when a running turn settles.
|
|
929
|
+
React.useEffect(() => {
|
|
930
|
+
void refreshLatest()
|
|
931
|
+
}, [sessionId, refreshLatest])
|
|
932
|
+
|
|
933
|
+
React.useEffect(() => {
|
|
934
|
+
if (running === false) void refreshLatest()
|
|
935
|
+
}, [running, refreshLatest])
|
|
936
|
+
|
|
937
|
+
// Reload the active text as well as invalidating caches. Keep Monaco mounted
|
|
938
|
+
// and keep the first-reveal history so refreshing a viewed file retains its
|
|
939
|
+
// scroll position. loadFile cancels the previous read before starting anew.
|
|
940
|
+
const snapshotRef = React.useRef(null)
|
|
941
|
+
React.useEffect(() => {
|
|
942
|
+
if (snapshotRef.current === snapshotKey) return
|
|
943
|
+
snapshotRef.current = snapshotKey
|
|
944
|
+
cacheGenerationRef.current += 1
|
|
945
|
+
fileCacheRef.current = new Map()
|
|
946
|
+
diffCacheRef.current = new Map()
|
|
947
|
+
if (activePath) {
|
|
948
|
+
lastSnapshotLoadRef.current = { key: snapshotKey, path: activePath }
|
|
949
|
+
void loadFile(activePath, { force: true })
|
|
950
|
+
}
|
|
951
|
+
}, [snapshotKey, activePath, loadFile])
|
|
952
|
+
|
|
953
|
+
// The baseline restore puts the remembered active file in place without going
|
|
954
|
+
// through openPath(); read it once here, from the render that already carries
|
|
955
|
+
// the restored baseline/revision (so historical content is read at its commit).
|
|
956
|
+
// The snapshot effect above may already have force-loaded that exact file while
|
|
957
|
+
// settling the restored revision (which changes snapshotKey); never fetch twice.
|
|
958
|
+
React.useEffect(() => {
|
|
959
|
+
if (!restoreLoadRef.current) return
|
|
960
|
+
if (!activePath) return
|
|
961
|
+
restoreLoadRef.current = false
|
|
962
|
+
const already = lastSnapshotLoadRef.current
|
|
963
|
+
if (already && already.key === snapshotKey && already.path === activePath) return
|
|
964
|
+
snapshotRef.current = snapshotKey
|
|
965
|
+
cacheGenerationRef.current += 1
|
|
966
|
+
fileCacheRef.current = new Map()
|
|
967
|
+
diffCacheRef.current = new Map()
|
|
968
|
+
void loadFile(activePath, { force: true })
|
|
969
|
+
}, [activePath, snapshotKey, loadFile])
|
|
970
|
+
|
|
971
|
+
// A mode switch invalidates the current decorations before the matching
|
|
972
|
+
// baseline effect repaints from cache in the same commit.
|
|
973
|
+
const previousModeRef = React.useRef(mode)
|
|
974
|
+
React.useEffect(() => {
|
|
975
|
+
if (previousModeRef.current === mode) return
|
|
976
|
+
previousModeRef.current = mode
|
|
977
|
+
applyDiffEntry({ status: 'none' })
|
|
978
|
+
}, [mode, applyDiffEntry])
|
|
979
|
+
|
|
980
|
+
// Depend on the file record itself: a refresh can return identical text while
|
|
981
|
+
// React batches away the intermediate loading state. Its new record still
|
|
982
|
+
// has to restart the comparison after cache invalidation.
|
|
983
|
+
// TURN baseline: cached per (turn seq, path), fetched once.
|
|
984
|
+
React.useEffect(() => {
|
|
985
|
+
if (!activePath || mode !== 'turn' || !summary || !latest || !latest.available) return undefined
|
|
986
|
+
if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
|
|
987
|
+
const index = changedIndexOf(activePath)
|
|
988
|
+
if (index < 0) {
|
|
989
|
+
applyDiffEntry({ status: 'none' }, activePath, file.text)
|
|
990
|
+
return undefined
|
|
991
|
+
}
|
|
992
|
+
const key = diffCacheKey(activePath)
|
|
993
|
+
const cached = key ? diffCacheRef.current.get(key) : undefined
|
|
994
|
+
if (cached) {
|
|
995
|
+
applyDiffEntry(cached, activePath, file.text)
|
|
996
|
+
return undefined
|
|
997
|
+
}
|
|
998
|
+
let cancelled = false
|
|
999
|
+
const generation = cacheGenerationRef.current
|
|
1000
|
+
let showedLoading = false
|
|
1001
|
+
if (!comparison || comparison.status !== 'ready') showedLoading = true
|
|
1002
|
+
if (showedLoading) setComparison({ status: 'loading', index })
|
|
1003
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1004
|
+
apiGet('diff', { sessionId, seq: latest.seq, index }, controller ? controller.signal : undefined)
|
|
1005
|
+
.then((value) => {
|
|
1006
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1007
|
+
const entry = { status: 'ready', value, index }
|
|
1008
|
+
if (key) diffCacheRef.current.set(key, entry)
|
|
1009
|
+
applyDiffEntry(entry, activePath, file.text)
|
|
1010
|
+
})
|
|
1011
|
+
.catch((failure) => {
|
|
1012
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1013
|
+
const status = failure && failure.status === 404 ? 'missing' : 'error'
|
|
1014
|
+
if (key) diffCacheRef.current.set(key, { status, message: failure && failure.message })
|
|
1015
|
+
applyDiffEntry({ status, message: failure && failure.message }, activePath, file.text)
|
|
1016
|
+
if (failure && failure.status === 404) notify(t('error.sessionGone'), 'warn')
|
|
1017
|
+
})
|
|
1018
|
+
return () => { cancelled = true; controller?.abort() }
|
|
1019
|
+
}, [activePath, mode, snapshotKey, latest && latest.seq, latest && latest.available, summary, file, sessionId, changedIndexOf, diffCacheKey, applyDiffEntry, notify, t])
|
|
1020
|
+
|
|
1021
|
+
// Git baseline: HEAD ↔ working tree for the active file (cached per path).
|
|
1022
|
+
React.useEffect(() => {
|
|
1023
|
+
if (mode !== 'git' || !activePath) return undefined
|
|
1024
|
+
if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
|
|
1025
|
+
if (!gitStatus || gitStatus.available !== true) {
|
|
1026
|
+
applyDiffEntry({ status: 'unavailable', message: t('mode.git.unavailable') }, activePath, file.text)
|
|
1027
|
+
return undefined
|
|
1028
|
+
}
|
|
1029
|
+
const key = diffCacheKey(activePath)
|
|
1030
|
+
const cached = key ? diffCacheRef.current.get(key) : undefined
|
|
1031
|
+
if (cached) {
|
|
1032
|
+
applyDiffEntry(cached, activePath, file.text)
|
|
1033
|
+
return undefined
|
|
1034
|
+
}
|
|
1035
|
+
let cancelled = false
|
|
1036
|
+
const generation = cacheGenerationRef.current
|
|
1037
|
+
setComparison({ status: 'loading' })
|
|
1038
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1039
|
+
apiGet('gitDiff', { sessionId, path: activePath, scope: changeScope }, controller ? controller.signal : undefined)
|
|
1040
|
+
.then((value) => {
|
|
1041
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1042
|
+
let entry
|
|
1043
|
+
if (!value || !value.kind) entry = { status: 'error' }
|
|
1044
|
+
else if (value.kind === 'text') entry = { status: 'ready', value }
|
|
1045
|
+
else if (value.kind === 'deleted') entry = { status: 'deleted' }
|
|
1046
|
+
else entry = { status: 'unavailable', kind: value.kind }
|
|
1047
|
+
if (key) diffCacheRef.current.set(key, entry)
|
|
1048
|
+
applyDiffEntry(entry, activePath, file.text)
|
|
1049
|
+
})
|
|
1050
|
+
.catch((failure) => {
|
|
1051
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1052
|
+
applyDiffEntry({ status: 'error', message: failure && failure.message }, activePath, file.text)
|
|
1053
|
+
})
|
|
1054
|
+
return () => { cancelled = true; controller?.abort() }
|
|
1055
|
+
}, [mode, changeScope, activePath, snapshotKey, file, gitStatus && gitStatus.available, sessionId, diffCacheKey, applyDiffEntry, t])
|
|
1056
|
+
|
|
1057
|
+
// Revision baseline: compare the active file against the selected git version
|
|
1058
|
+
// (`commit` = rev^1 → rev, `since` = rev → worktree). The tree's compare
|
|
1059
|
+
// payload has to be ready first: without it the diff columns are meaningless.
|
|
1060
|
+
React.useEffect(() => {
|
|
1061
|
+
if (mode !== 'revision' || !activePath || !revision) return undefined
|
|
1062
|
+
if (!fileCurrent || (file.status !== 'ready' && file.status !== 'missing')) return undefined
|
|
1063
|
+
if (!revisionData) return undefined
|
|
1064
|
+
if (revisionData.available !== true) {
|
|
1065
|
+
applyDiffEntry({ status: 'unavailable', message: t('status.revision.unavailable') }, activePath, file.text)
|
|
1066
|
+
return undefined
|
|
1067
|
+
}
|
|
1068
|
+
const key = diffCacheKey(activePath)
|
|
1069
|
+
const cached = key ? diffCacheRef.current.get(key) : undefined
|
|
1070
|
+
if (cached) {
|
|
1071
|
+
applyDiffEntry(cached, activePath, file.text)
|
|
1072
|
+
return undefined
|
|
1073
|
+
}
|
|
1074
|
+
let cancelled = false
|
|
1075
|
+
const generation = cacheGenerationRef.current
|
|
1076
|
+
setComparison({ status: 'loading' })
|
|
1077
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1078
|
+
apiGet('gitFile', { sessionId, path: activePath, rev: revision.sha, scope: revision.scope }, controller ? controller.signal : undefined)
|
|
1079
|
+
.then((value) => {
|
|
1080
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1081
|
+
let entry
|
|
1082
|
+
if (!value || !value.kind) entry = { status: 'error' }
|
|
1083
|
+
else if (value.kind === 'text') entry = { status: 'ready', value }
|
|
1084
|
+
else if (value.kind === 'deleted') entry = { status: 'deleted' }
|
|
1085
|
+
else entry = { status: 'unavailable', kind: value.kind }
|
|
1086
|
+
if (key) diffCacheRef.current.set(key, entry)
|
|
1087
|
+
applyDiffEntry(entry, activePath, file.text)
|
|
1088
|
+
})
|
|
1089
|
+
.catch((failure) => {
|
|
1090
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1091
|
+
applyDiffEntry({ status: 'error', message: failure && failure.message }, activePath, file.text)
|
|
1092
|
+
})
|
|
1093
|
+
return () => { cancelled = true; controller?.abort() }
|
|
1094
|
+
}, [mode, activePath, snapshotKey, file, revision && revision.sha, revision && revision.scope, revisionData, sessionId, diffCacheKey, applyDiffEntry, t])
|
|
1095
|
+
|
|
1096
|
+
// Prefetch file text + comparison for the turn's changed files in the
|
|
1097
|
+
// background, so the first click on a file in the tree also renders its diff
|
|
1098
|
+
// without a round-trip (limited concurrency, cache-only; never touches the
|
|
1099
|
+
// live page state).
|
|
1100
|
+
React.useEffect(() => {
|
|
1101
|
+
if (mode !== 'turn' || !latest || !latest.available || !summary || !sessionId) return undefined
|
|
1102
|
+
const seq = latest.seq
|
|
1103
|
+
const generation = cacheGenerationRef.current
|
|
1104
|
+
const files = Array.isArray(summary.files) ? summary.files : []
|
|
1105
|
+
const targets = []
|
|
1106
|
+
for (const fileEntry of files) {
|
|
1107
|
+
const rel = toWorkspaceRel((fileEntry && (fileEntry.path || fileEntry.display)) || '', cwdFromStore)
|
|
1108
|
+
if (!rel || !isCodeFile(rel) || targets.includes(rel)) continue
|
|
1109
|
+
targets.push(rel)
|
|
1110
|
+
if (targets.length >= 8) break
|
|
1111
|
+
}
|
|
1112
|
+
let cancelled = false
|
|
1113
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
1114
|
+
prefetchAbortRef.current = controller
|
|
1115
|
+
const run = async () => {
|
|
1116
|
+
for (const path of targets) {
|
|
1117
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1118
|
+
const key = 'turn:' + String(seq) + ':' + path
|
|
1119
|
+
const haveFile = fileCacheRef.current.get(path)?.status === 'ready'
|
|
1120
|
+
const haveDiff = diffCacheRef.current.has(key)
|
|
1121
|
+
if (haveFile && haveDiff) continue
|
|
1122
|
+
const index = changedIndexOf(path)
|
|
1123
|
+
if (index < 0) continue
|
|
1124
|
+
try {
|
|
1125
|
+
const [fileValue, diffValue] = await Promise.all([
|
|
1126
|
+
haveFile ? null : apiGet('file', { sessionId, path }, controller ? controller.signal : undefined),
|
|
1127
|
+
haveDiff ? null : apiGet('diff', { sessionId, seq, index }, controller ? controller.signal : undefined),
|
|
1128
|
+
])
|
|
1129
|
+
if (cancelled || generation !== cacheGenerationRef.current) return
|
|
1130
|
+
if (fileValue) {
|
|
1131
|
+
const record = { ...fileRecordOf(path, fileValue), snapshotKey }
|
|
1132
|
+
if (record.status === 'ready' || record.status === 'handoff') fileCacheRef.current.set(path, record)
|
|
1133
|
+
}
|
|
1134
|
+
if (diffValue) diffCacheRef.current.set(key, { status: 'ready', value: diffValue, index })
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
/* prefetch is best effort */
|
|
1137
|
+
}
|
|
1138
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
void run()
|
|
1142
|
+
return () => {
|
|
1143
|
+
cancelled = true
|
|
1144
|
+
controller?.abort()
|
|
1145
|
+
if (prefetchAbortRef.current === controller) prefetchAbortRef.current = null
|
|
1146
|
+
}
|
|
1147
|
+
}, [mode, snapshotKey, latest && latest.seq, latest && latest.available, summary, sessionId, cwdFromStore, changedIndexOf])
|
|
1148
|
+
|
|
1149
|
+
// A search hit / Quick Open result may ask to reveal a specific line once the
|
|
1150
|
+
// file content is in and its comparison settled.
|
|
1151
|
+
React.useEffect(() => {
|
|
1152
|
+
const pending = pendingRevealRef.current
|
|
1153
|
+
if (!pending) return
|
|
1154
|
+
if (!file || file.status !== 'ready' || file.path !== pending.path) return
|
|
1155
|
+
if (comparison && comparison.status === 'loading') return
|
|
1156
|
+
setReveal((previous) => ({ ...pending, line: Math.max(1, pending.line), nonce: previous.nonce + 1 }))
|
|
1157
|
+
}, [file, comparison])
|
|
1158
|
+
|
|
1159
|
+
/** Open a file in the code area (adding a tab and recording nav history). */
|
|
1160
|
+
const openPath = React.useCallback((path, options) => {
|
|
1161
|
+
const rel = normalizeRel(path)
|
|
1162
|
+
if (!rel) return
|
|
1163
|
+
if (!options?.location) pendingRevealRef.current = null
|
|
1164
|
+
if (activePath && activePath !== rel) {
|
|
1165
|
+
const history = historyRef.current
|
|
1166
|
+
history.past.push(activePath)
|
|
1167
|
+
if (history.past.length > 100) history.past.shift()
|
|
1168
|
+
history.future = []
|
|
1169
|
+
}
|
|
1170
|
+
setActivePath(rel)
|
|
1171
|
+
setOpenTabs((tabs) => (tabs.includes(rel) ? tabs : tabs.concat(rel)))
|
|
1172
|
+
setMenuOpen(false)
|
|
1173
|
+
if (!(options && options.silent)) void loadFile(rel)
|
|
1174
|
+
}, [activePath, loadFile])
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Open one file with the exact baseline the CHANGES row points at:
|
|
1178
|
+
* staged = HEAD↔index, unstaged = index↔worktree.
|
|
1179
|
+
*/
|
|
1180
|
+
const openChangeDiff = React.useCallback((path, scope) => {
|
|
1181
|
+
const rel = normalizeRel(path)
|
|
1182
|
+
if (!rel) return
|
|
1183
|
+
setChangeScope(scope === 'staged' ? 'staged' : 'unstaged')
|
|
1184
|
+
setMode('git')
|
|
1185
|
+
setModeTouched(true)
|
|
1186
|
+
openPath(rel)
|
|
1187
|
+
}, [openPath])
|
|
1188
|
+
|
|
1189
|
+
// SPEC §3.1/§10.2: opening the page selects the first changed file and jumps
|
|
1190
|
+
// to its first diff (once per mount). Works for both the turn baseline and
|
|
1191
|
+
// the uncommitted-changes baseline this plugin defaults to.
|
|
1192
|
+
React.useEffect(() => {
|
|
1193
|
+
if (autoOpenedRef.current) return
|
|
1194
|
+
let files = null
|
|
1195
|
+
if (mode === 'turn') {
|
|
1196
|
+
if (!latest || latest.available !== true || !latest.summary) return
|
|
1197
|
+
files = Array.isArray(latest.summary.files) ? latest.summary.files : []
|
|
1198
|
+
} else if (mode === 'git') {
|
|
1199
|
+
if (!gitStatus || gitStatus.available !== true) return
|
|
1200
|
+
files = Array.isArray(gitStatus.files) ? gitStatus.files.map((entry) => ({ path: entry.path })) : []
|
|
1201
|
+
} else {
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
if (files.length === 0) return
|
|
1205
|
+
autoOpenedRef.current = true
|
|
1206
|
+
const mapped = files.map((fileEntry) => toWorkspaceRel(fileEntry.path || fileEntry.display || '', cwdFromStore))
|
|
1207
|
+
const candidates = mapped.filter((rel) => rel !== '' && !rel.startsWith('..') && !/^[a-zA-Z]:/.test(rel))
|
|
1208
|
+
if (candidates.length === 0) return
|
|
1209
|
+
const target = candidates.find((rel) => isCodeFile(rel)) || candidates[0]
|
|
1210
|
+
openPath(target)
|
|
1211
|
+
}, [latest, gitStatus, mode, cwdFromStore, openPath])
|
|
1212
|
+
|
|
1213
|
+
/** Close one tab, selecting a neighbour. */
|
|
1214
|
+
const closeTab = React.useCallback((path) => {
|
|
1215
|
+
const tabs = openTabs
|
|
1216
|
+
const index = tabs.indexOf(path)
|
|
1217
|
+
const next = tabs.filter((tab) => tab !== path)
|
|
1218
|
+
setOpenTabs(next)
|
|
1219
|
+
if (activePath !== path) return
|
|
1220
|
+
const fallback = next[Math.min(Math.max(index, 0), next.length - 1)] || null
|
|
1221
|
+
setActivePath(fallback)
|
|
1222
|
+
if (fallback) void loadFile(fallback)
|
|
1223
|
+
else setFile(null)
|
|
1224
|
+
}, [openTabs, activePath, loadFile])
|
|
1225
|
+
|
|
1226
|
+
/** Cycle through open tabs. */
|
|
1227
|
+
const cycleTab = React.useCallback((delta) => {
|
|
1228
|
+
if (openTabs.length === 0) return
|
|
1229
|
+
const index = Math.max(0, openTabs.indexOf(activePath))
|
|
1230
|
+
const next = (index + delta + openTabs.length) % openTabs.length
|
|
1231
|
+
const path = openTabs[next]
|
|
1232
|
+
if (path) openPath(path)
|
|
1233
|
+
}, [openTabs, activePath, openPath])
|
|
1234
|
+
|
|
1235
|
+
/** Navigate the in-page history. */
|
|
1236
|
+
const navigateHistory = React.useCallback((direction) => {
|
|
1237
|
+
const history = historyRef.current
|
|
1238
|
+
const current = activePath
|
|
1239
|
+
if (direction === 'back') {
|
|
1240
|
+
const previous = history.past.pop()
|
|
1241
|
+
if (!previous) return
|
|
1242
|
+
if (current) history.future.push(current)
|
|
1243
|
+
setActivePath(previous)
|
|
1244
|
+
setOpenTabs((tabs) => (tabs.includes(previous) ? tabs : tabs.concat(previous)))
|
|
1245
|
+
void loadFile(previous)
|
|
1246
|
+
} else {
|
|
1247
|
+
const next = history.future.pop()
|
|
1248
|
+
if (!next) return
|
|
1249
|
+
if (current) history.past.push(current)
|
|
1250
|
+
setActivePath(next)
|
|
1251
|
+
setOpenTabs((tabs) => (tabs.includes(next) ? tabs : tabs.concat(next)))
|
|
1252
|
+
void loadFile(next)
|
|
1253
|
+
}
|
|
1254
|
+
}, [activePath, loadFile])
|
|
1255
|
+
|
|
1256
|
+
/** Reveal a change group by index. */
|
|
1257
|
+
const goToChange = React.useCallback((index) => {
|
|
1258
|
+
if (!regions.length) return
|
|
1259
|
+
const clamped = ((index % regions.length) + regions.length) % regions.length
|
|
1260
|
+
setHunkIndex(clamped)
|
|
1261
|
+
setReveal((previous) => ({ line: regions[clamped].start, nonce: previous.nonce + 1 }))
|
|
1262
|
+
}, [regions])
|
|
1263
|
+
|
|
1264
|
+
/** Open a file (defaults to the active one) in the builtin DSH preview. */
|
|
1265
|
+
const openInPreview = React.useCallback((targetPath) => {
|
|
1266
|
+
const path = typeof targetPath === 'string' && targetPath !== '' ? targetPath : activePath
|
|
1267
|
+
if (!path) return
|
|
1268
|
+
const ok = openResourceSafe(right, fileAddressOf(sessionId, path))
|
|
1269
|
+
if (!ok) notify(t('error.openFailed', { message: 'preview' }), 'warn')
|
|
1270
|
+
}, [activePath, sessionId, right, notify, t])
|
|
1271
|
+
|
|
1272
|
+
/** Copy the active file's workspace-relative path. */
|
|
1273
|
+
const copyActivePath = React.useCallback(async () => {
|
|
1274
|
+
if (!activePath) return
|
|
1275
|
+
const ok = await copyText(activePath)
|
|
1276
|
+
notify(ok ? t('common.copied') : t('action.copyPath'), ok ? 'info' : 'warn')
|
|
1277
|
+
if (copiedTimer) clearTimeout(copiedTimer)
|
|
1278
|
+
copiedTimer = setTimeout(() => { copiedTimer = null }, 2000)
|
|
1279
|
+
}, [activePath, notify, t])
|
|
1280
|
+
|
|
1281
|
+
/** Refresh everything currently shown (the single toolbar refresh button). */
|
|
1282
|
+
const refreshAll = React.useCallback(() => {
|
|
1283
|
+
cacheGenerationRef.current += 1
|
|
1284
|
+
prefetchAbortRef.current?.abort()
|
|
1285
|
+
fileCacheRef.current = new Map()
|
|
1286
|
+
diffCacheRef.current = new Map()
|
|
1287
|
+
void refreshLatest()
|
|
1288
|
+
void loadGitStatus()
|
|
1289
|
+
if (activePath) void loadFile(activePath, { force: true })
|
|
1290
|
+
setTreeRefreshToken((value) => value + 1)
|
|
1291
|
+
// Reload the revision comparison (and therefore the tree's file list).
|
|
1292
|
+
setRevisionRefreshToken((value) => value + 1)
|
|
1293
|
+
}, [refreshLatest, loadGitStatus, activePath, loadFile])
|
|
1294
|
+
|
|
1295
|
+
/** Apply one editor font size (clamped) to the live editor, if any. */
|
|
1296
|
+
const applyFontSize = React.useCallback((size) => {
|
|
1297
|
+
const next = Math.min(28, Math.max(9, Math.round(Number(size) || 13)))
|
|
1298
|
+
fontSizeRef.current = next
|
|
1299
|
+
setFontSize(next)
|
|
1300
|
+
try { localStorage.setItem('dsh-git-changes.fontSize', String(next)) } catch (error) { /* optional */ }
|
|
1301
|
+
editorControlsRef.current?.setFontSize?.(next)
|
|
1302
|
+
}, [])
|
|
1303
|
+
|
|
1304
|
+
/** Open the picker overlay in one mode. The revisions picker keeps its scope. */
|
|
1305
|
+
const openPicker = React.useCallback((targetMode) => {
|
|
1306
|
+
setMenuOpen(false)
|
|
1307
|
+
if (targetMode === 'revisions') {
|
|
1308
|
+
setPicker({ mode: 'revisions', scope: revision && revision.scope === 'since' ? 'since' : 'commit' })
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
setPicker({ mode: targetMode })
|
|
1312
|
+
}, [revision])
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Switch the page to one git revision baseline. `HEAD` is the one-click
|
|
1316
|
+
* latest-commit shortcut; the host resolves it to the real commit.
|
|
1317
|
+
* @param {object} next revision-ish record (`sha`, `short`, `subject`, `date`)
|
|
1318
|
+
* @param {'commit'|'since'} [scope] comparison scope
|
|
1319
|
+
*/
|
|
1320
|
+
const applyRevision = React.useCallback((next, scope) => {
|
|
1321
|
+
if (!next || typeof next.sha !== 'string' || next.sha === '') return
|
|
1322
|
+
setRevision({
|
|
1323
|
+
sha: next.sha,
|
|
1324
|
+
short: next.short ? String(next.short) : next.sha,
|
|
1325
|
+
subject: next.subject ? String(next.subject) : '',
|
|
1326
|
+
date: next.date ? String(next.date) : undefined,
|
|
1327
|
+
scope: scope === 'since' ? 'since' : 'commit',
|
|
1328
|
+
})
|
|
1329
|
+
setMode('revision')
|
|
1330
|
+
setModeTouched(true)
|
|
1331
|
+
}, [])
|
|
1332
|
+
|
|
1333
|
+
/** Change the revisions-picker scope (commit vs since) without closing it. */
|
|
1334
|
+
const changeRevisionScope = React.useCallback((scope) => {
|
|
1335
|
+
const next = scope === 'since' ? 'since' : 'commit'
|
|
1336
|
+
setPicker((current) => (current && current.mode === 'revisions' ? { ...current, scope: next } : current))
|
|
1337
|
+
}, [])
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Apply one picked revision from the picker. A raw typed expression is
|
|
1341
|
+
* resolved through the host (`gitRevisions` with `q`); failures keep the
|
|
1342
|
+
* picker open and surface a warning.
|
|
1343
|
+
* @param {object} picked commit record or `{raw:string}`
|
|
1344
|
+
*/
|
|
1345
|
+
const pickRevision = React.useCallback(async (picked) => {
|
|
1346
|
+
if (!picked) return
|
|
1347
|
+
const scope = picker && picker.mode === 'revisions' && picker.scope === 'since' ? 'since' : 'commit'
|
|
1348
|
+
if (typeof picked.raw === 'string') {
|
|
1349
|
+
const raw = picked.raw.trim()
|
|
1350
|
+
if (!raw) return
|
|
1351
|
+
try {
|
|
1352
|
+
const value = await apiGet('gitRevisions', { sessionId, q: raw })
|
|
1353
|
+
const resolved = value && value.resolved
|
|
1354
|
+
if (!resolved) {
|
|
1355
|
+
const reason = value && (value.resolveError || value.reason)
|
|
1356
|
+
notify(t('picker.revisions.resolveFailed', { message: reason || raw }), 'warn')
|
|
1357
|
+
return
|
|
1358
|
+
}
|
|
1359
|
+
applyRevision(resolved, scope)
|
|
1360
|
+
setPicker(null)
|
|
1361
|
+
} catch (failure) {
|
|
1362
|
+
notify(t('picker.revisions.resolveFailed', { message: failure && failure.message ? failure.message : String(failure) }), 'warn')
|
|
1363
|
+
}
|
|
1364
|
+
return
|
|
1365
|
+
}
|
|
1366
|
+
applyRevision(picked, scope)
|
|
1367
|
+
setPicker(null)
|
|
1368
|
+
}, [picker, sessionId, notify, t, applyRevision])
|
|
1369
|
+
|
|
1370
|
+
// In-page shortcut fallback: when the DSH shortcut dispatcher does not consume
|
|
1371
|
+
// the chord (or the binding could not be registered), handle the VS Code–style
|
|
1372
|
+
// keys while focus is inside the review page. Bubble phase + defaultPrevented
|
|
1373
|
+
// keeps native bindings (e.g. Monaco's own Ctrl+G) authoritative.
|
|
1374
|
+
React.useEffect(() => {
|
|
1375
|
+
if (picker) return undefined
|
|
1376
|
+
const onKeyDown = (event) => {
|
|
1377
|
+
try {
|
|
1378
|
+
if (event.defaultPrevented) return
|
|
1379
|
+
const root = rootRef.current
|
|
1380
|
+
if (!root || !(event.target instanceof Node) || !root.contains(event.target)) return
|
|
1381
|
+
const primary = event.ctrlKey || event.metaKey
|
|
1382
|
+
const key = String(event.key || '').toLowerCase()
|
|
1383
|
+
const alt = event.altKey
|
|
1384
|
+
const shift = event.shiftKey
|
|
1385
|
+
if (primary && !alt && !shift && key === 'p') { event.preventDefault(); openPicker('quickopen'); return }
|
|
1386
|
+
if (primary && !alt && shift && key === 'p') { event.preventDefault(); openPicker('commands'); return }
|
|
1387
|
+
if (primary && !alt && shift && key === 'f') { event.preventDefault(); openPicker('search'); return }
|
|
1388
|
+
if (primary && !alt && !shift && key === 'g') { event.preventDefault(); openPicker('line'); return }
|
|
1389
|
+
if (primary && !alt && shift && key === 'e') { event.preventDefault(); setTreeOpen((value) => !value) }
|
|
1390
|
+
if (primary && !alt && shift && key === 'o') { event.preventDefault(); void handlersRef.current.outline?.() }
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
log('warn', 'key fallback failed', error)
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
window.addEventListener('keydown', onKeyDown)
|
|
1396
|
+
return () => window.removeEventListener('keydown', onKeyDown)
|
|
1397
|
+
}, [picker, openPicker])
|
|
1398
|
+
|
|
1399
|
+
/** Open a picked file (optionally revealing a line from a search hit). */
|
|
1400
|
+
const pickFile = React.useCallback((path, line, column, range) => {
|
|
1401
|
+
setPicker(null)
|
|
1402
|
+
const rel = normalizeRel(path)
|
|
1403
|
+
if (!rel) return
|
|
1404
|
+
const target = typeof line === 'number' && line > 0 ? { path: rel, line, column: column || 1, endLine: range?.endLine, endColumn: range?.endColumn } : null
|
|
1405
|
+
pendingRevealRef.current = target
|
|
1406
|
+
if (target && rel === activePath && file?.status === 'ready') {
|
|
1407
|
+
setReveal((previous) => ({ ...target, nonce: previous.nonce + 1 }))
|
|
1408
|
+
return
|
|
1409
|
+
}
|
|
1410
|
+
openPath(rel, { location: target })
|
|
1411
|
+
}, [openPath, activePath, file?.status])
|
|
1412
|
+
|
|
1413
|
+
// Host-version self-check: a stale Host half (LSP or git-version routes not
|
|
1414
|
+
// loaded yet) would otherwise fail silently as "no definition found" or an
|
|
1415
|
+
// empty version picker.
|
|
1416
|
+
React.useEffect(() => {
|
|
1417
|
+
let cancelled = false
|
|
1418
|
+
apiGet('config')
|
|
1419
|
+
.then((value) => {
|
|
1420
|
+
if (cancelled || !value) return
|
|
1421
|
+
const features = value.features || {}
|
|
1422
|
+
if (features.lsp !== true || Number(value.phase) < 3) {
|
|
1423
|
+
notify('插件 Host 端仍是旧版本(phase ' + String(value.phase) + '):LSP 路由不可用,请重启 DSH 后再试', 'warn')
|
|
1424
|
+
} else if (features.gitRevisions !== true) {
|
|
1425
|
+
notify(t('error.hostOutdated'), 'warn')
|
|
1426
|
+
}
|
|
1427
|
+
})
|
|
1428
|
+
.catch(() => { /* config probe is optional */ })
|
|
1429
|
+
return () => { cancelled = true }
|
|
1430
|
+
}, [notify, t])
|
|
1431
|
+
// Register the language-server providers once Monaco is ready, and feed the
|
|
1432
|
+
// bridge the active document whenever the model changes.
|
|
1433
|
+
React.useEffect(() => {
|
|
1434
|
+
if (!monaco) return
|
|
1435
|
+
lspRegisterProviders(monaco, {
|
|
1436
|
+
notify: (text, kind) => notify(text, kind),
|
|
1437
|
+
onMissing: (missing) => setLspMissing(missing),
|
|
1438
|
+
onUnsupported: (language) => notify(t('lsp.unsupported', { language }), 'warn'),
|
|
1439
|
+
openLocation: (path, line, column, range) => {
|
|
1440
|
+
// Historical content is shown at its commit, but navigation locations
|
|
1441
|
+
// for other files are computed against the worktree; the reveal line is
|
|
1442
|
+
// therefore only approximate there.
|
|
1443
|
+
const crossFile = historical && normalizeRel(path) !== activePath
|
|
1444
|
+
pickFile(path, line, column, range)
|
|
1445
|
+
if (crossFile) notify(t('lsp.historicalCrossFile'), 'info')
|
|
1446
|
+
},
|
|
1447
|
+
showLocations: (locations, kind) => { setMenuOpen(false); setPicker({ mode: 'locations', locations, kind }) },
|
|
1448
|
+
})
|
|
1449
|
+
}, [monaco, notify, pickFile, historical, activePath, t])
|
|
1450
|
+
|
|
1451
|
+
/** One click = confirmation: send the install request to the Agent. */
|
|
1452
|
+
const installLspFromAgent = React.useCallback(async () => {
|
|
1453
|
+
const missing = lspMissing
|
|
1454
|
+
if (!missing) return
|
|
1455
|
+
const result = await lspAskAgentToInstall(missing.install, missing.language)
|
|
1456
|
+
if (result === 'sent') {
|
|
1457
|
+
lspForgetMissing()
|
|
1458
|
+
setLspMissing(null)
|
|
1459
|
+
notify(t('lsp.sent'), 'info')
|
|
1460
|
+
return
|
|
1461
|
+
}
|
|
1462
|
+
if (result === 'draft') {
|
|
1463
|
+
notify(t('lsp.inserted'), 'info')
|
|
1464
|
+
return
|
|
1465
|
+
}
|
|
1466
|
+
const ok = await copyText(missing.install)
|
|
1467
|
+
notify(ok ? t('lsp.copied') : missing.install, ok ? 'info' : 'warn')
|
|
1468
|
+
}, [lspMissing, notify, t])
|
|
1469
|
+
|
|
1470
|
+
/** Copy the install command for manual installation. */
|
|
1471
|
+
const copyLspInstall = React.useCallback(async () => {
|
|
1472
|
+
const missing = lspMissing
|
|
1473
|
+
if (!missing) return
|
|
1474
|
+
const ok = await copyText(missing.install)
|
|
1475
|
+
notify(ok ? t('lsp.copied') : missing.install, ok ? 'info' : 'warn')
|
|
1476
|
+
}, [lspMissing, notify, t])
|
|
1477
|
+
|
|
1478
|
+
/** Show the live LSP state (debug aid for missing-server/route problems). */
|
|
1479
|
+
const showLspDiagnostics = React.useCallback(async () => {
|
|
1480
|
+
setMenuOpen(false)
|
|
1481
|
+
const rows = await lspDiagnostics()
|
|
1482
|
+
notify(rows.join(' | '), 'info')
|
|
1483
|
+
try { console.log('[dsh-git-changes lsp]', rows) } catch (error) { /* ignore */ }
|
|
1484
|
+
}, [notify])
|
|
1485
|
+
|
|
1486
|
+
/** Fetch the document outline and list it in the picker. */
|
|
1487
|
+
const openOutline = React.useCallback(async () => {
|
|
1488
|
+
setMenuOpen(false)
|
|
1489
|
+
const symbols = await lspSymbols()
|
|
1490
|
+
if (!Array.isArray(symbols) || symbols.length === 0) {
|
|
1491
|
+
notify(t('lsp.noSymbols'), 'info')
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
setPicker({ mode: 'outline', symbols })
|
|
1495
|
+
}, [notify, t])
|
|
1496
|
+
|
|
1497
|
+
// F12 / Shift+F12 in capture phase: Monaco's built-in goToReferences action
|
|
1498
|
+
// otherwise swallows Shift+F12 (it has no provider for our models) and only
|
|
1499
|
+
// F12 would reach our editor command. Capture also covers the non-editor area
|
|
1500
|
+
// of the page, where the editor never sees the key at all.
|
|
1501
|
+
React.useEffect(() => {
|
|
1502
|
+
const onF12 = (event) => {
|
|
1503
|
+
if (event.key !== 'F12' || event.altKey || event.ctrlKey || event.metaKey) return
|
|
1504
|
+
if (picker || event.repeat) return
|
|
1505
|
+
try {
|
|
1506
|
+
const root = rootRef.current
|
|
1507
|
+
if (!root || !(event.target instanceof Node) || !root.contains(event.target)) return
|
|
1508
|
+
event.preventDefault()
|
|
1509
|
+
event.stopPropagation()
|
|
1510
|
+
let position = null
|
|
1511
|
+
try {
|
|
1512
|
+
const editor = editorControlsRef.current?.getEditor?.()
|
|
1513
|
+
position = lspPosition(editor)
|
|
1514
|
+
} catch (error) { /* disposed editor */ }
|
|
1515
|
+
if (!position || !lspRuntime.model) { notify('请先打开代码文件并选中要查询的符号', 'info'); return }
|
|
1516
|
+
if (event.shiftKey) void lspReferences(position.lineNumber, position.column)
|
|
1517
|
+
else void lspDefinition(position.lineNumber, position.column)
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
log('warn', 'F12 handler failed', error)
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
window.addEventListener('keydown', onF12, true)
|
|
1523
|
+
return () => window.removeEventListener('keydown', onF12, true)
|
|
1524
|
+
}, [picker, notify])
|
|
1525
|
+
|
|
1526
|
+
// Palette entries: the plugin command catalog plus a few view actions.
|
|
1527
|
+
const paletteCommands = React.useMemo(() => {
|
|
1528
|
+
const list = COMMAND_SPECS.map((spec) => ({ id: spec.id, label: () => t(spec.message), hint: '' }))
|
|
1529
|
+
list.push(
|
|
1530
|
+
{ id: 'mode:git', label: () => t('commands.switchGit'), hint: '' },
|
|
1531
|
+
{ id: 'mode:turn', label: () => t('commands.switchTurn'), hint: '' },
|
|
1532
|
+
{ id: 'mode:revision-head', label: () => t('commands.switchHead'), hint: '' },
|
|
1533
|
+
{ id: 'mode:revision', label: () => t('commands.switchRevision'), hint: '' },
|
|
1534
|
+
{ id: 'mode:code', label: () => t('commands.switchCode'), hint: '' },
|
|
1535
|
+
{ id: 'shortcuts', label: () => t('action.shortcuts'), hint: 'keys' },
|
|
1536
|
+
)
|
|
1537
|
+
return list
|
|
1538
|
+
}, [t])
|
|
1539
|
+
|
|
1540
|
+
/** Run one command chosen from the palette or a location/outline picker. */
|
|
1541
|
+
const pickCommand = React.useCallback((id) => {
|
|
1542
|
+
const current = picker
|
|
1543
|
+
setPicker(null)
|
|
1544
|
+
if (typeof id === 'string' && id.startsWith('loc:') && current && Array.isArray(current.locations)) {
|
|
1545
|
+
const location = current.locations[Number(id.slice(4))]
|
|
1546
|
+
if (location) pickFile(location.path, location.line, location.column, location)
|
|
1547
|
+
return
|
|
1548
|
+
}
|
|
1549
|
+
if (typeof id === 'string' && id.startsWith('sym:') && current) {
|
|
1550
|
+
const symbol = current.symbols?.[Number(id.slice(4))]
|
|
1551
|
+
if (symbol) pickFile(activePath, symbol.line, symbol.column)
|
|
1552
|
+
return
|
|
1553
|
+
}
|
|
1554
|
+
const handler = handlersRef.current[id]
|
|
1555
|
+
if (typeof handler === 'function') handler()
|
|
1556
|
+
}, [picker, pickFile, activePath])
|
|
1557
|
+
|
|
1558
|
+
// Command handlers shared with the official-shortcut dispatcher.
|
|
1559
|
+
handlersRef.current = {
|
|
1560
|
+
nextChange: () => goToChange(hunkIndex + 1),
|
|
1561
|
+
prevChange: () => goToChange(hunkIndex - 1),
|
|
1562
|
+
nextTab: () => cycleTab(1),
|
|
1563
|
+
prevTab: () => cycleTab(-1),
|
|
1564
|
+
closeTab: () => { if (activePath) closeTab(activePath) },
|
|
1565
|
+
navBack: () => navigateHistory('back'),
|
|
1566
|
+
navForward: () => navigateHistory('forward'),
|
|
1567
|
+
toggleTree: () => setTreeOpen((value) => !value),
|
|
1568
|
+
fontLarger: () => applyFontSize(fontSizeRef.current + 1),
|
|
1569
|
+
fontSmaller: () => applyFontSize(fontSizeRef.current - 1),
|
|
1570
|
+
fontReset: () => applyFontSize(13),
|
|
1571
|
+
'mode:turn': () => {
|
|
1572
|
+
if (!(latest && latest.available)) return
|
|
1573
|
+
setMode('turn')
|
|
1574
|
+
setModeTouched(true)
|
|
1575
|
+
},
|
|
1576
|
+
'mode:git': () => {
|
|
1577
|
+
if (!(gitStatus && gitStatus.available === true)) return
|
|
1578
|
+
setMode('git')
|
|
1579
|
+
setModeTouched(true)
|
|
1580
|
+
},
|
|
1581
|
+
'mode:revision-head': () => applyRevision({ sha: 'HEAD', short: 'HEAD', subject: t('mode.commit') }, 'commit'),
|
|
1582
|
+
'mode:revision': () => {
|
|
1583
|
+
if (revision) {
|
|
1584
|
+
setMode('revision')
|
|
1585
|
+
setModeTouched(true)
|
|
1586
|
+
return
|
|
1587
|
+
}
|
|
1588
|
+
openPicker('revisions')
|
|
1589
|
+
},
|
|
1590
|
+
'picker:revisions': () => openPicker('revisions'),
|
|
1591
|
+
'mode:code': () => {
|
|
1592
|
+
setMode('code')
|
|
1593
|
+
setModeTouched(true)
|
|
1594
|
+
},
|
|
1595
|
+
refresh: refreshAll,
|
|
1596
|
+
copyPath: copyActivePath,
|
|
1597
|
+
openInPreview,
|
|
1598
|
+
openReview: () => { if (activePath) openPath(activePath) },
|
|
1599
|
+
outline: openOutline,
|
|
1600
|
+
lspDiagnostics: () => { void showLspDiagnostics() },
|
|
1601
|
+
lspRecheck: () => { setMenuOpen(false); void lspCheckAvailability(true) },
|
|
1602
|
+
quickOpen: () => openPicker('quickopen'),
|
|
1603
|
+
quickOpenAlt: () => openPicker('quickopen'),
|
|
1604
|
+
gotoLine: () => openPicker('line'),
|
|
1605
|
+
searchWorkspace: () => openPicker('search'),
|
|
1606
|
+
commandPalette: () => openPicker('commands'),
|
|
1607
|
+
shortcuts: () => openPicker('keys'),
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Publish this page as the active command target.
|
|
1611
|
+
React.useEffect(() => {
|
|
1612
|
+
const controller = {
|
|
1613
|
+
sessionId,
|
|
1614
|
+
contains: (element) => Boolean(rootRef.current && element && rootRef.current.contains(element)),
|
|
1615
|
+
command: (id) => {
|
|
1616
|
+
const handler = handlersRef.current[id]
|
|
1617
|
+
if (typeof handler !== 'function') return false
|
|
1618
|
+
try {
|
|
1619
|
+
handler()
|
|
1620
|
+
return true
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
log('error', 'command failed', id, error)
|
|
1623
|
+
return false
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
}
|
|
1627
|
+
runtimeState.page = controller
|
|
1628
|
+
runtimeState.sessionId = sessionId
|
|
1629
|
+
return () => {
|
|
1630
|
+
if (runtimeState.page === controller) runtimeState.page = null
|
|
1631
|
+
}
|
|
1632
|
+
}, [sessionId])
|
|
1633
|
+
|
|
1634
|
+
// Keep the latest session id available for the global "open review" command.
|
|
1635
|
+
React.useEffect(() => {
|
|
1636
|
+
if (sessionId) runtimeState.sessionId = sessionId
|
|
1637
|
+
}, [sessionId])
|
|
1638
|
+
|
|
1639
|
+
// Auto-refresh when the turn closes (workspace/changes was appended before
|
|
1640
|
+
// turn/end), and when the window regains focus.
|
|
1641
|
+
React.useEffect(() => {
|
|
1642
|
+
if (!sessionId) return undefined
|
|
1643
|
+
let dispose
|
|
1644
|
+
try {
|
|
1645
|
+
const conversation = runtimeState.conversation
|
|
1646
|
+
const binding = conversation && typeof conversation.binding === 'function' ? conversation.binding(sessionId) : undefined
|
|
1647
|
+
const source = binding && binding.openTurn
|
|
1648
|
+
if (source && typeof source.subscribe === 'function' && typeof source.getSnapshot === 'function') {
|
|
1649
|
+
let previous = source.getSnapshot()
|
|
1650
|
+
const listener = () => {
|
|
1651
|
+
const next = source.getSnapshot()
|
|
1652
|
+
if (previous !== undefined && (next === undefined || next !== previous)) void refreshLatest()
|
|
1653
|
+
previous = next
|
|
1654
|
+
}
|
|
1655
|
+
dispose = source.subscribe(listener)
|
|
1656
|
+
}
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
/* optional signal */
|
|
1659
|
+
}
|
|
1660
|
+
return () => {
|
|
1661
|
+
try {
|
|
1662
|
+
if (typeof dispose === 'function') dispose()
|
|
1663
|
+
} catch (error) { /* ignore */ }
|
|
1664
|
+
}
|
|
1665
|
+
}, [sessionId, refreshLatest])
|
|
1666
|
+
|
|
1667
|
+
React.useEffect(() => {
|
|
1668
|
+
// Window focus fires constantly while working across apps; a full refresh on
|
|
1669
|
+
// every focus repainted the editor and could visibly jump the diff, so it is
|
|
1670
|
+
// rate-limited to one check per few seconds.
|
|
1671
|
+
let last = 0
|
|
1672
|
+
const refresh = () => {
|
|
1673
|
+
const now = Date.now()
|
|
1674
|
+
if (now - last < 3000) return
|
|
1675
|
+
last = now
|
|
1676
|
+
void refreshLatest()
|
|
1677
|
+
}
|
|
1678
|
+
const onVisibility = () => { if (!document.hidden) refresh() }
|
|
1679
|
+
window.addEventListener('focus', refresh)
|
|
1680
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
1681
|
+
return () => {
|
|
1682
|
+
window.removeEventListener('focus', refresh)
|
|
1683
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
1684
|
+
}
|
|
1685
|
+
}, [refreshLatest])
|
|
1686
|
+
|
|
1687
|
+
// Unmount: cancel in-flight reads.
|
|
1688
|
+
React.useEffect(() => () => {
|
|
1689
|
+
fileTokenRef.current += 1
|
|
1690
|
+
try { abortRef.current?.abort() } catch (error) { /* ignore */ }
|
|
1691
|
+
}, [])
|
|
1692
|
+
|
|
1693
|
+
// Tree width drag (rAF-throttled: one state update per frame).
|
|
1694
|
+
const startTreeResize = (event) => {
|
|
1695
|
+
try {
|
|
1696
|
+
event.preventDefault()
|
|
1697
|
+
} catch (error) { /* ignore */ }
|
|
1698
|
+
const startX = event.clientX
|
|
1699
|
+
const startWidth = treeWidth
|
|
1700
|
+
let frame = 0
|
|
1701
|
+
let pendingWidth = startWidth
|
|
1702
|
+
const flush = () => {
|
|
1703
|
+
frame = 0
|
|
1704
|
+
setTreeWidth(pendingWidth)
|
|
1705
|
+
}
|
|
1706
|
+
const onMove = (moveEvent) => {
|
|
1707
|
+
const delta = startX - moveEvent.clientX
|
|
1708
|
+
pendingWidth = Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, startWidth + delta))
|
|
1709
|
+
if (frame) return
|
|
1710
|
+
try { frame = requestAnimationFrame(flush) } catch (error) { flush() }
|
|
1711
|
+
}
|
|
1712
|
+
const onUp = () => {
|
|
1713
|
+
if (frame) {
|
|
1714
|
+
try { cancelAnimationFrame(frame) } catch (error) { /* ignore */ }
|
|
1715
|
+
frame = 0
|
|
1716
|
+
flush()
|
|
1717
|
+
}
|
|
1718
|
+
window.removeEventListener('mousemove', onMove)
|
|
1719
|
+
window.removeEventListener('mouseup', onUp)
|
|
1720
|
+
}
|
|
1721
|
+
window.addEventListener('mousemove', onMove)
|
|
1722
|
+
window.addEventListener('mouseup', onUp)
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Close the overflow/dropdown menus on outside pointer or Escape. Pointer
|
|
1726
|
+
// presses on the trigger wrappers themselves are ignored: otherwise the
|
|
1727
|
+
// outside handler closes the menu and the trigger's click toggles it back on,
|
|
1728
|
+
// which reads as "the button sometimes does nothing".
|
|
1729
|
+
React.useEffect(() => {
|
|
1730
|
+
if (!menuOpen) return undefined
|
|
1731
|
+
const onPointer = (event) => {
|
|
1732
|
+
try {
|
|
1733
|
+
const target = event.target
|
|
1734
|
+
if (target instanceof Element) {
|
|
1735
|
+
if (target.closest('.drp-dropdown, .drp-dropdown-menu')) return
|
|
1736
|
+
}
|
|
1737
|
+
} catch (error) { /* ignore */ }
|
|
1738
|
+
setMenuOpen(false)
|
|
1739
|
+
}
|
|
1740
|
+
const onKey = (event) => {
|
|
1741
|
+
if (event.key === 'Escape') {
|
|
1742
|
+
setMenuOpen(false)
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
window.addEventListener('mousedown', onPointer)
|
|
1746
|
+
window.addEventListener('keydown', onKey)
|
|
1747
|
+
return () => {
|
|
1748
|
+
window.removeEventListener('mousedown', onPointer)
|
|
1749
|
+
window.removeEventListener('keydown', onKey)
|
|
1750
|
+
}
|
|
1751
|
+
}, [menuOpen])
|
|
1752
|
+
|
|
1753
|
+
const monacoReady = Boolean(monaco) && !monacoError
|
|
1754
|
+
const comparisonCurrent = comparison && comparison.path === activePath
|
|
1755
|
+
&& comparison.snapshotKey === snapshotKey && comparison.generation === cacheGenerationRef.current
|
|
1756
|
+
&& comparison.key === diffCacheKey(activePath)
|
|
1757
|
+
const deletedReady = fileCurrent && file.status === 'missing' && comparisonCurrent
|
|
1758
|
+
&& comparison.status === 'ready' && comparison.value && comparison.value.kind === 'text'
|
|
1759
|
+
&& comparison.value.before === true && comparison.value.after === false
|
|
1760
|
+
&& Array.isArray(comparison.value.hunks)
|
|
1761
|
+
const reviewedFile = fileCurrent && file.status === 'ready' ? file
|
|
1762
|
+
: deletedReady ? { ...file, status: 'ready' } : null
|
|
1763
|
+
const fileReady = Boolean(reviewedFile)
|
|
1764
|
+
const missingPending = fileCurrent && file.status === 'missing' && mode !== 'code'
|
|
1765
|
+
&& (!comparisonCurrent || comparison.status === 'loading')
|
|
1766
|
+
React.useEffect(() => {
|
|
1767
|
+
// Even a formerly empty document can be deleted without changing the
|
|
1768
|
+
// Monaco model's text, so do not rely only on onModelReady for teardown.
|
|
1769
|
+
if (deletedReady) lspClearDocument()
|
|
1770
|
+
}, [deletedReady, activePath])
|
|
1771
|
+
// Historical (= commit) content is a real document for the language server:
|
|
1772
|
+
// the Host bridge overlays the shown text, so navigation keeps working. Both
|
|
1773
|
+
// directions re-announce it through MonacoHost (the model text can be
|
|
1774
|
+
// byte-identical even when the snapshot kind changed).
|
|
1775
|
+
const historicalRef = React.useRef(false)
|
|
1776
|
+
React.useEffect(() => {
|
|
1777
|
+
if (historicalRef.current === historical) return
|
|
1778
|
+
historicalRef.current = historical
|
|
1779
|
+
setDocumentEpoch((value) => value + 1)
|
|
1780
|
+
}, [historical])
|
|
1781
|
+
const tabsRow = openTabs.length > 0
|
|
1782
|
+
? h('div', { className: 'drp-tabs', role: 'tablist' }, openTabs.map((path, index) => {
|
|
1783
|
+
const isActive = path === activePath
|
|
1784
|
+
const showDivider = !isActive && index > 0 && openTabs[index - 1] !== activePath
|
|
1785
|
+
return h(
|
|
1786
|
+
React.Fragment,
|
|
1787
|
+
{ key: path },
|
|
1788
|
+
showDivider ? h('span', { className: 'drp-tab-divider' }) : null,
|
|
1789
|
+
h(
|
|
1790
|
+
'div',
|
|
1791
|
+
{
|
|
1792
|
+
className: cx('drp-tab', isActive && 'drp-tab-active'),
|
|
1793
|
+
role: 'tab',
|
|
1794
|
+
'aria-selected': isActive,
|
|
1795
|
+
title: path,
|
|
1796
|
+
onClick: () => openPath(path),
|
|
1797
|
+
onAuxClick: (event) => { if (event.button === 1) closeTab(path) },
|
|
1798
|
+
},
|
|
1799
|
+
h('span', { className: 'drp-tab-fill' }),
|
|
1800
|
+
h(FileIcon, { path, scheme }),
|
|
1801
|
+
h('span', { className: 'drp-tab-name' }, basename(path)),
|
|
1802
|
+
mode === 'git' && isActive
|
|
1803
|
+
? h('span', { className: 'drp-tab-scope' }, t(changeScope === 'staged' ? 'changes.tag.staged' : 'changes.tag.unstaged'))
|
|
1804
|
+
: null,
|
|
1805
|
+
h('span', { className: 'drp-tab-actions' },
|
|
1806
|
+
h('button', {
|
|
1807
|
+
type: 'button',
|
|
1808
|
+
className: 'drp-tab-close',
|
|
1809
|
+
title: t('action.closeTab'),
|
|
1810
|
+
'aria-label': t('action.closeTab'),
|
|
1811
|
+
onClick: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } closeTab(path) },
|
|
1812
|
+
}, h(Codicon, { char: 'close' }))),
|
|
1813
|
+
),
|
|
1814
|
+
)
|
|
1815
|
+
}))
|
|
1816
|
+
: null
|
|
1817
|
+
|
|
1818
|
+
const menu = menuOpen
|
|
1819
|
+
? h(
|
|
1820
|
+
'div',
|
|
1821
|
+
{
|
|
1822
|
+
className: 'drp-dropdown-menu',
|
|
1823
|
+
role: 'menu',
|
|
1824
|
+
onMouseDown: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } },
|
|
1825
|
+
},
|
|
1826
|
+
h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(fontSizeRef.current + 1); } }, t('action.fontLarger') + ' +'),
|
|
1827
|
+
h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(fontSizeRef.current - 1); } }, t('action.fontSmaller') + ' −'),
|
|
1828
|
+
h('button', { type: 'button', className: 'drp-menu-item', onClick: () => { applyFontSize(13); setMenuOpen(false) } }, t('action.fontReset') + ' (' + String(fontSize) + 'px)'),
|
|
1829
|
+
h('div', { className: 'drp-menu-sep' }),
|
|
1830
|
+
h('button', { type: 'button', className: 'drp-menu-item', onClick: () => openPicker('keys') }, t('action.shortcuts')),
|
|
1831
|
+
)
|
|
1832
|
+
: null
|
|
1833
|
+
|
|
1834
|
+
const pickerItems = React.useMemo(() => {
|
|
1835
|
+
if (!picker) return []
|
|
1836
|
+
if (picker.mode === 'locations') {
|
|
1837
|
+
return (picker.locations || []).map((location, index) => ({
|
|
1838
|
+
id: 'loc:' + index,
|
|
1839
|
+
label: () => location.path + ':' + location.line + ':' + location.column,
|
|
1840
|
+
hint: '',
|
|
1841
|
+
}))
|
|
1842
|
+
}
|
|
1843
|
+
if (picker.mode === 'outline') {
|
|
1844
|
+
return (picker.symbols || []).map((symbol, index) => ({
|
|
1845
|
+
id: 'sym:' + index,
|
|
1846
|
+
label: () => ' '.repeat(Math.max(0, symbol.depth || 0)) + symbol.name,
|
|
1847
|
+
hint: symbol.detail || '',
|
|
1848
|
+
}))
|
|
1849
|
+
}
|
|
1850
|
+
return null
|
|
1851
|
+
}, [picker])
|
|
1852
|
+
|
|
1853
|
+
const pickerNode = picker
|
|
1854
|
+
? h(PickerOverlay, {
|
|
1855
|
+
mode: picker.mode,
|
|
1856
|
+
locationKind: picker.kind,
|
|
1857
|
+
t,
|
|
1858
|
+
sessionId,
|
|
1859
|
+
commands: pickerItems || (picker.mode === 'keys' ? buildKeyHelp(t) : paletteCommands),
|
|
1860
|
+
activePath,
|
|
1861
|
+
lineCount: fileReady ? file.lines : 0,
|
|
1862
|
+
// Revisions picker contract (optional props for every other mode).
|
|
1863
|
+
revisionScope: picker.mode === 'revisions' ? (picker.scope || 'commit') : undefined,
|
|
1864
|
+
onRevisionScopeChange: changeRevisionScope,
|
|
1865
|
+
onPickRevision: pickRevision,
|
|
1866
|
+
onPickFile: pickFile,
|
|
1867
|
+
onPickCommand: pickCommand,
|
|
1868
|
+
onClose: () => setPicker(null),
|
|
1869
|
+
})
|
|
1870
|
+
: null
|
|
1871
|
+
|
|
1872
|
+
// VS Code–style notification: one click on 安装 confirms and sends the install
|
|
1873
|
+
// request to the Agent; the card closes once the request is submitted.
|
|
1874
|
+
const lspBanner = lspMissing
|
|
1875
|
+
? h('div', { className: 'drp-lsp-toast', role: 'alertdialog', 'aria-label': t('lsp.askAgent') },
|
|
1876
|
+
h('div', { className: 'drp-lsp-toast-head' },
|
|
1877
|
+
h('span', { className: 'drp-lsp-toast-title' }, t('lsp.toastTitle', { language: lspMissing.language })),
|
|
1878
|
+
h('button', { type: 'button', className: 'drp-icon-btn', title: t('common.close'), onClick: () => { lspForgetMissing(); setLspMissing(null) } }, '✕')),
|
|
1879
|
+
h('div', { className: 'drp-lsp-toast-body' }, t('lsp.toastBody')),
|
|
1880
|
+
h('code', { className: 'drp-lsp-toast-cmd', title: lspMissing.install }, lspMissing.install),
|
|
1881
|
+
h('div', { className: 'drp-lsp-toast-actions' },
|
|
1882
|
+
h('button', { type: 'button', className: 'drp-btn drp-btn-primary', onClick: () => { void installLspFromAgent() } }, t('lsp.install')),
|
|
1883
|
+
h('button', { type: 'button', className: 'drp-btn', onClick: () => { void copyLspInstall() } }, t('lsp.copyInstall')),
|
|
1884
|
+
h('button', { type: 'button', className: 'drp-btn', onClick: () => { void lspCheckAvailability(true) } }, t('lsp.recheck')),
|
|
1885
|
+
),
|
|
1886
|
+
)
|
|
1887
|
+
: null
|
|
1888
|
+
|
|
1889
|
+
// The Monaco host stays mounted for the whole page lifetime. A file switch only
|
|
1890
|
+
// swaps the model inside the existing editor, so the previous file's scroll
|
|
1891
|
+
// position survives and the new content/diff paints immediately (the old code
|
|
1892
|
+
// unmounted the editor on every switch, recreating it and jumping to line 1).
|
|
1893
|
+
// The language server must see the same document in both layout modes.
|
|
1894
|
+
const handleModelReady = (info) => {
|
|
1895
|
+
if (reviewedFile && reviewedFile.missing) {
|
|
1896
|
+
lspClearDocument()
|
|
1897
|
+
return
|
|
1898
|
+
}
|
|
1899
|
+
// Derive the LSP language from the file extension (Monaco's model
|
|
1900
|
+
// language depends on which tokenizers are registered and can be
|
|
1901
|
+
// plaintext); the bridge needs the language server's language id.
|
|
1902
|
+
// Historical content is handed over too: the bridge overlays this text,
|
|
1903
|
+
// so go-to-definition/references/hover/symbols work on the old snapshot
|
|
1904
|
+
// (diagnostics stay suppressed for it, see lspSetDocument).
|
|
1905
|
+
lspSetDocument({
|
|
1906
|
+
path: info.path,
|
|
1907
|
+
text: info.text,
|
|
1908
|
+
languageId: languageOf(info.path),
|
|
1909
|
+
monaco: info.monaco,
|
|
1910
|
+
model: info.model,
|
|
1911
|
+
sessionId,
|
|
1912
|
+
cwd: cwdFromStore,
|
|
1913
|
+
historical,
|
|
1914
|
+
})
|
|
1915
|
+
void lspCheckAvailability()
|
|
1916
|
+
}
|
|
1917
|
+
// Below 900px of editor width the side-by-side view stops reading well
|
|
1918
|
+
// (VS Code uses the same breakpoint for its automatic layout).
|
|
1919
|
+
const viewportWidth = typeof window !== 'undefined' && Number(window.innerWidth) > 0 ? Number(window.innerWidth) : 1280
|
|
1920
|
+
const editorWidth = Math.max(320, viewportWidth - (treeVisible && !narrow ? treeWidth : 0))
|
|
1921
|
+
const effectiveDiffLayout = mode === 'code' ? 'inline' : resolveDiffLayout(diffLayout, editorWidth)
|
|
1922
|
+
const editorCard = effectiveDiffLayout === 'side'
|
|
1923
|
+
? h(MonacoDiffHost, {
|
|
1924
|
+
monaco,
|
|
1925
|
+
file: reviewedFile,
|
|
1926
|
+
regions: fileReady && comparisonCurrent ? regions : [],
|
|
1927
|
+
sessionId,
|
|
1928
|
+
blame: historical ? null : blameLines,
|
|
1929
|
+
t,
|
|
1930
|
+
fontSize,
|
|
1931
|
+
reveal,
|
|
1932
|
+
scheme,
|
|
1933
|
+
documentEpoch,
|
|
1934
|
+
onCursor: (position) => cursorStore.set(position),
|
|
1935
|
+
onModelReady: handleModelReady,
|
|
1936
|
+
onReady: (controls) => {
|
|
1937
|
+
editorControlsRef.current = controls
|
|
1938
|
+
if (controls && typeof controls.setFontSize === 'function') controls.setFontSize(fontSizeRef.current)
|
|
1939
|
+
},
|
|
1940
|
+
onError: (error) => setMonacoError(error),
|
|
1941
|
+
})
|
|
1942
|
+
: h(MonacoHost, {
|
|
1943
|
+
monaco,
|
|
1944
|
+
file: reviewedFile,
|
|
1945
|
+
regions: fileReady && comparisonCurrent ? regions : [],
|
|
1946
|
+
sessionId,
|
|
1947
|
+
blame: historical ? null : blameLines,
|
|
1948
|
+
t,
|
|
1949
|
+
wrap: false,
|
|
1950
|
+
minimap: false,
|
|
1951
|
+
fontSize,
|
|
1952
|
+
reveal,
|
|
1953
|
+
scheme,
|
|
1954
|
+
documentEpoch,
|
|
1955
|
+
onCursor: (position) => cursorStore.set(position),
|
|
1956
|
+
onRequestLine: () => openPicker('line'),
|
|
1957
|
+
onRequestOutline: () => { void openOutline() },
|
|
1958
|
+
onModelReady: handleModelReady,
|
|
1959
|
+
onLsp: (kind, line, column) => { if (kind === 'definition') void lspDefinition(line, column); else if (kind === 'references') void lspReferences(line, column) },
|
|
1960
|
+
onReady: (controls) => {
|
|
1961
|
+
editorControlsRef.current = controls
|
|
1962
|
+
if (controls && typeof controls.setFontSize === 'function') controls.setFontSize(fontSizeRef.current)
|
|
1963
|
+
},
|
|
1964
|
+
onError: (error) => setMonacoError(error),
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
const codeArea = !activePath
|
|
1968
|
+
? h(EmptyState, { t, latest })
|
|
1969
|
+
: fileCurrent && file.status === 'handoff'
|
|
1970
|
+
? h(HandoffCard, {
|
|
1971
|
+
sessionId,
|
|
1972
|
+
path: activePath,
|
|
1973
|
+
kind: file.kind,
|
|
1974
|
+
t,
|
|
1975
|
+
right,
|
|
1976
|
+
absolutePath: file.absolutePath,
|
|
1977
|
+
bytes: file.bytes,
|
|
1978
|
+
})
|
|
1979
|
+
: fileCurrent && (file.status === 'error' || (file.status === 'missing' && !deletedReady && !missingPending))
|
|
1980
|
+
? h('div', { className: 'drp-empty' },
|
|
1981
|
+
h('div', { className: 'drp-empty-title' }, t('empty.error')),
|
|
1982
|
+
h('div', { className: 'drp-empty-body' }, file.error || ''),
|
|
1983
|
+
h('button', { type: 'button', className: 'drp-btn', onClick: () => loadFile(activePath, { force: true }) }, t('empty.retry')))
|
|
1984
|
+
: monacoError
|
|
1985
|
+
? h('div', { className: 'drp-empty' },
|
|
1986
|
+
h('div', { className: 'drp-empty-title' }, t('empty.error')),
|
|
1987
|
+
h('div', { className: 'drp-empty-body' }, String(monacoError && monacoError.message ? monacoError.message : monacoError)))
|
|
1988
|
+
: h(React.Fragment, null,
|
|
1989
|
+
monacoReady ? editorCard : null,
|
|
1990
|
+
!monacoReady || !fileReady
|
|
1991
|
+
? h('div', { className: 'drp-loading drp-loading-overlay' },
|
|
1992
|
+
h('span', { className: 'drp-spinner' }),
|
|
1993
|
+
latestLoading || (file && file.status === 'loading') ? t('status.loadingFile') : t('common.loading'))
|
|
1994
|
+
: null,
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
const revisionFileCount = revisionData && revisionData.available === true && Array.isArray(revisionData.files) ? revisionData.files.length : 0
|
|
1998
|
+
const revisionStatusText = revisionLoading || !revisionData
|
|
1999
|
+
? t('status.revision.loading')
|
|
2000
|
+
: revisionData.available !== true
|
|
2001
|
+
? t('status.revision.unavailable')
|
|
2002
|
+
: revisionData.scope === 'since'
|
|
2003
|
+
? t('status.revision.since', {
|
|
2004
|
+
base: revisionData.base ? revisionData.base.short : '',
|
|
2005
|
+
count: String(revisionFileCount),
|
|
2006
|
+
})
|
|
2007
|
+
: t('status.revision.commit', {
|
|
2008
|
+
ref: revisionData.target ? revisionData.target.short : '',
|
|
2009
|
+
base: revisionData.base ? revisionData.base.short : '',
|
|
2010
|
+
count: String(revisionFileCount),
|
|
2011
|
+
historical: historical ? t('status.revision.historical') : t('status.revision.worktree'),
|
|
2012
|
+
})
|
|
2013
|
+
|
|
2014
|
+
const statusText = status
|
|
2015
|
+
? status.text
|
|
2016
|
+
: file && file.status === 'loading'
|
|
2017
|
+
? t('status.loadingFile')
|
|
2018
|
+
: comparison && comparison.status === 'loading'
|
|
2019
|
+
? t('status.diffLoading')
|
|
2020
|
+
: comparisonCurrent && comparison.status === 'unavailable' && comparison.kind === 'oversized'
|
|
2021
|
+
? t('status.diffOversized')
|
|
2022
|
+
: mode === 'turn'
|
|
2023
|
+
? (latest && latest.available
|
|
2024
|
+
? t('status.mode.turn', { turn: String(latest.turn), seq: String(latest.seq) })
|
|
2025
|
+
: t('status.mode.turn.stale'))
|
|
2026
|
+
: mode === 'git'
|
|
2027
|
+
? t('status.mode.git')
|
|
2028
|
+
: mode === 'revision'
|
|
2029
|
+
? revisionStatusText
|
|
2030
|
+
: t('status.mode.code')
|
|
2031
|
+
|
|
2032
|
+
const gitStatusText = mode === 'revision'
|
|
2033
|
+
? (historical ? t('status.revision.historical') : t('status.revision.worktree'))
|
|
2034
|
+
: mode === 'git' && gitStatus
|
|
2035
|
+
? (gitStatus.available === true
|
|
2036
|
+
? t('status.git', { count: String((gitStatus.files || []).length) })
|
|
2037
|
+
: t('status.git.unavailable'))
|
|
2038
|
+
: null
|
|
2039
|
+
|
|
2040
|
+
// Revision browsing always needs the tree (there is no staged state to show).
|
|
2041
|
+
const effectivePanelMode = mode === 'revision' ? 'tree' : panelMode
|
|
2042
|
+
const treeNode = effectivePanelMode === 'changes'
|
|
2043
|
+
? h(ChangesPanel, {
|
|
2044
|
+
sessionId,
|
|
2045
|
+
activePath,
|
|
2046
|
+
initialStatus: gitStatus,
|
|
2047
|
+
onOpenChangeDiff: openChangeDiff,
|
|
2048
|
+
onOpenCommit: (commit) => applyRevision(commit, 'commit'),
|
|
2049
|
+
onOpenFile: (path) => {
|
|
2050
|
+
setMode('code')
|
|
2051
|
+
setModeTouched(true)
|
|
2052
|
+
openPath(normalizeRel(path))
|
|
2053
|
+
},
|
|
2054
|
+
onWorkspaceChanged: () => setDocumentEpoch((previous) => previous + 1),
|
|
2055
|
+
onNotify: (text, kind) => notify(text, kind),
|
|
2056
|
+
scheme,
|
|
2057
|
+
t,
|
|
2058
|
+
})
|
|
2059
|
+
: h(FileTree, {
|
|
2060
|
+
sessionId,
|
|
2061
|
+
cwd: cwdFromStore,
|
|
2062
|
+
summaryFiles: summary ? summary.files : null,
|
|
2063
|
+
gitStatus: mode === 'revision' ? revisionData : gitStatus,
|
|
2064
|
+
activePath,
|
|
2065
|
+
treeMode: mode === 'revision' ? 'revision' : mode,
|
|
2066
|
+
// Opening from the project tree is plain viewing/editing; only the
|
|
2067
|
+
// revision tree keeps the historical diff baseline.
|
|
2068
|
+
onOpen: (path) => {
|
|
2069
|
+
if (mode !== 'revision') {
|
|
2070
|
+
setMode('code')
|
|
2071
|
+
setModeTouched(true)
|
|
2072
|
+
}
|
|
2073
|
+
openPath(path)
|
|
2074
|
+
},
|
|
2075
|
+
reveal: null,
|
|
2076
|
+
refreshToken: treeRefreshToken,
|
|
2077
|
+
onPreview: (path) => openInPreview(path),
|
|
2078
|
+
onNotify: (text, kind) => notify(text, kind),
|
|
2079
|
+
t,
|
|
2080
|
+
scheme,
|
|
2081
|
+
})
|
|
2082
|
+
|
|
2083
|
+
// Solid popover surfaces per scheme: DSH menu/overlay tokens are translucent,
|
|
2084
|
+
// which reads as washed-out over the code area (user report). All plugin
|
|
2085
|
+
// popovers therefore use opaque plugin-owned colors derived from the scheme.
|
|
2086
|
+
const solidTheme = scheme === 'light'
|
|
2087
|
+
? {
|
|
2088
|
+
'--drp-panel-bg': '#ffffff',
|
|
2089
|
+
'--drp-panel-bg-2': '#f3f4f6',
|
|
2090
|
+
'--drp-panel-border': 'rgba(31, 35, 40, 0.16)',
|
|
2091
|
+
'--drp-panel-text': '#1f2328',
|
|
2092
|
+
'--drp-git-M': '#9a6700',
|
|
2093
|
+
'--drp-git-A': '#1a7f37',
|
|
2094
|
+
'--drp-git-D': '#cf222e',
|
|
2095
|
+
'--drp-git-U': '#0969da',
|
|
2096
|
+
'--drp-git-R': '#0969da',
|
|
2097
|
+
}
|
|
2098
|
+
: {
|
|
2099
|
+
'--drp-panel-bg': '#1c2128',
|
|
2100
|
+
'--drp-panel-bg-2': '#22272e',
|
|
2101
|
+
'--drp-panel-border': 'rgba(240, 246, 252, 0.16)',
|
|
2102
|
+
'--drp-panel-text': '#e6edf3',
|
|
2103
|
+
'--drp-git-M': '#e3b341',
|
|
2104
|
+
'--drp-git-A': '#56d364',
|
|
2105
|
+
'--drp-git-D': '#ff7b72',
|
|
2106
|
+
'--drp-git-U': '#79c0ff',
|
|
2107
|
+
'--drp-git-R': '#79c0ff',
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
return h(
|
|
2111
|
+
'div',
|
|
2112
|
+
{
|
|
2113
|
+
className: cx('drp-root', narrow && 'drp-root-narrow'),
|
|
2114
|
+
ref: rootRef,
|
|
2115
|
+
'data-drp-view': '1',
|
|
2116
|
+
'data-dsh-git-changes-page': '1',
|
|
2117
|
+
'data-drp-scheme': scheme,
|
|
2118
|
+
style: solidTheme,
|
|
2119
|
+
},
|
|
2120
|
+
h(
|
|
2121
|
+
'div',
|
|
2122
|
+
{ className: 'drp-main' },
|
|
2123
|
+
tabsRow,
|
|
2124
|
+
h(
|
|
2125
|
+
'div',
|
|
2126
|
+
{ className: 'drp-toolbar' },
|
|
2127
|
+
h(
|
|
2128
|
+
'div',
|
|
2129
|
+
{ className: 'drp-toolbar-row' },
|
|
2130
|
+
h(IconButton, { title: t('action.quickOpen'), label: h(Codicon, { char: 'search' }), onClick: () => openPicker('quickopen') }),
|
|
2131
|
+
h('span', { className: 'drp-path', title: activePath || t('tab.title') }, activePath ? ellipsisMiddle(activePath, narrow ? 24 : 46) : t('tab.title')),
|
|
2132
|
+
// Historical-baseline mode hides the changes panel, so keep an
|
|
2133
|
+
// explicit way back to the worktree baseline in the toolbar.
|
|
2134
|
+
mode === 'revision'
|
|
2135
|
+
? h('button', {
|
|
2136
|
+
type: 'button',
|
|
2137
|
+
className: cx('drp-btn', 'drp-exit-revision'),
|
|
2138
|
+
title: t('action.exitRevision.hint'),
|
|
2139
|
+
'aria-label': t('action.exitRevision.hint'),
|
|
2140
|
+
onClick: () => handlersRef.current['mode:git']?.(),
|
|
2141
|
+
}, '← ' + t('action.exitRevision'))
|
|
2142
|
+
: null,
|
|
2143
|
+
h('span', { className: 'drp-spacer' }),
|
|
2144
|
+
// The plugin reviews uncommitted changes; the active baseline is shown
|
|
2145
|
+
// as a plain label instead of a diff-mode picker.
|
|
2146
|
+
mode !== 'code' && comparisonCurrent
|
|
2147
|
+
? h('button', {
|
|
2148
|
+
type: 'button',
|
|
2149
|
+
className: cx('drp-btn', 'drp-diff-layout'),
|
|
2150
|
+
title: t('diff.layout') + ': ' + t('diff.' + diffLayout),
|
|
2151
|
+
'aria-label': t('diff.layout') + ': ' + t('diff.' + diffLayout),
|
|
2152
|
+
onClick: () => setDiffLayout((current) => current === 'auto' ? 'side' : current === 'side' ? 'inline' : 'auto'),
|
|
2153
|
+
}, t('diff.' + diffLayout))
|
|
2154
|
+
: null,
|
|
2155
|
+
regions.length > 0
|
|
2156
|
+
? h('div', { className: 'drp-change-nav' },
|
|
2157
|
+
h(IconButton, {
|
|
2158
|
+
title: t('cmd.prevChange') + ' (Shift+F7)',
|
|
2159
|
+
label: '↑',
|
|
2160
|
+
onClick: () => goToChange(hunkIndex - 1),
|
|
2161
|
+
}),
|
|
2162
|
+
h('span', {
|
|
2163
|
+
className: 'drp-change-nav-count',
|
|
2164
|
+
title: t('status.hunks', { count: String(regions.length) }),
|
|
2165
|
+
}, String(hunkIndex + 1) + '/' + String(regions.length)),
|
|
2166
|
+
h(IconButton, {
|
|
2167
|
+
title: t('cmd.nextChange') + ' (F7)',
|
|
2168
|
+
label: '↓',
|
|
2169
|
+
onClick: () => goToChange(hunkIndex + 1),
|
|
2170
|
+
}))
|
|
2171
|
+
: null,
|
|
2172
|
+
h(IconButton, { title: t('action.refresh'), label: h(Codicon, { char: 'refresh' }), onClick: refreshAll }),
|
|
2173
|
+
h(IconButton, {
|
|
2174
|
+
title: (treeOpen ? t('tree.hide') : t('tree.show')) + ' (' + shortcutModifierLabel('primary') + '+Shift+E)',
|
|
2175
|
+
label: h(Codicon, { char: 'files' }),
|
|
2176
|
+
active: treeOpen,
|
|
2177
|
+
onClick: () => setTreeOpen((value) => !value),
|
|
2178
|
+
}),
|
|
2179
|
+
h(
|
|
2180
|
+
'div',
|
|
2181
|
+
{ className: 'drp-dropdown' },
|
|
2182
|
+
h(IconButton, { title: t('action.more'), label: h(Codicon, { char: 'ellipsis' }), onClick: () => { setMenuOpen((value) => !value) } }),
|
|
2183
|
+
menu,
|
|
2184
|
+
),
|
|
2185
|
+
),
|
|
2186
|
+
),
|
|
2187
|
+
lspBanner,
|
|
2188
|
+
h('div', { className: 'drp-body' }, codeArea),
|
|
2189
|
+
h(ReviewStatusBar, { statusText, file: reviewedFile, gitText: gitStatusText, t, narrow }),
|
|
2190
|
+
pickerNode,
|
|
2191
|
+
),
|
|
2192
|
+
narrow
|
|
2193
|
+
? treeOpen
|
|
2194
|
+
? h('div', { className: 'drp-drawer-overlay', onClick: () => setTreeOpen(false) },
|
|
2195
|
+
h('div', { className: 'drp-drawer', style: { width: Math.max(TREE_WIDTH_MIN, Math.min(TREE_WIDTH_MAX, treeWidth)) + 'px' }, onClick: (event) => { try { event.stopPropagation() } catch (error) { /* ignore */ } } },
|
|
2196
|
+
treeNode,
|
|
2197
|
+
))
|
|
2198
|
+
: null
|
|
2199
|
+
: treeVisible
|
|
2200
|
+
? h(React.Fragment, null,
|
|
2201
|
+
h('div', { className: 'drp-tree-resizer', title: t('status.treeWidth', { width: String(treeWidth) }), onMouseDown: startTreeResize }),
|
|
2202
|
+
h('div', { className: 'drp-tree-panel', style: { width: treeWidth + 'px' } },
|
|
2203
|
+
mode === 'revision'
|
|
2204
|
+
? null
|
|
2205
|
+
: h('div', { className: 'drp-panel-switch drp-seg', role: 'tablist' },
|
|
2206
|
+
['changes', 'tree'].map((key) => h('button', {
|
|
2207
|
+
key,
|
|
2208
|
+
type: 'button',
|
|
2209
|
+
role: 'tab',
|
|
2210
|
+
'aria-selected': effectivePanelMode === key,
|
|
2211
|
+
className: cx('drp-seg-btn', effectivePanelMode === key && 'drp-seg-btn-on'),
|
|
2212
|
+
title: t(key === 'changes' ? 'changes.mode.changes' : 'changes.mode.tree'),
|
|
2213
|
+
onClick: () => setPanelMode(key),
|
|
2214
|
+
}, t(key === 'changes' ? 'changes.mode.changes' : 'changes.mode.tree')))),
|
|
2215
|
+
treeNode),
|
|
2216
|
+
)
|
|
2217
|
+
: null,
|
|
2218
|
+
)
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* The tab-kind body: reads the tab record via `useTabInfo()` and renders the page.
|
|
2223
|
+
* Keyed by session: a different conversation gets a fresh page instance, whose
|
|
2224
|
+
* mount restores that session's remembered tabs/baseline from `pageSessionStore`.
|
|
2225
|
+
* @param {object} props slot props
|
|
2226
|
+
* @returns {any} React element
|
|
2227
|
+
*/
|
|
2228
|
+
function ReviewTabBody(props) {
|
|
2229
|
+
const { sessionId, t, useSessions, useTabInfo, services } = props
|
|
2230
|
+
let tab = null
|
|
2231
|
+
try {
|
|
2232
|
+
if (typeof useTabInfo === 'function') tab = useTabInfo().tab
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
tab = null
|
|
2235
|
+
}
|
|
2236
|
+
const address = tab && tab.contentId ? tab.contentId : null
|
|
2237
|
+
return h(ReviewPage, {
|
|
2238
|
+
key: sessionId,
|
|
2239
|
+
sessionId,
|
|
2240
|
+
t,
|
|
2241
|
+
useSessions,
|
|
2242
|
+
useTabInfo,
|
|
2243
|
+
services,
|
|
2244
|
+
address,
|
|
2245
|
+
})
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Degraded central-view body (when the right sidebar services are unavailable).
|
|
2250
|
+
* @param {object} props slot props
|
|
2251
|
+
* @returns {any} React element
|
|
2252
|
+
*/
|
|
2253
|
+
function ReviewViewBody(props) {
|
|
2254
|
+
const { sessionId, t, useSessions, services } = props
|
|
2255
|
+
return h('div', { className: 'drp-view-wrap' },
|
|
2256
|
+
h('div', { className: 'drp-view-hint' }, t('error.sidebarUnavailable')),
|
|
2257
|
+
h(ReviewPage, { key: sessionId, sessionId, t, useSessions, services, address: null }),
|
|
2258
|
+
)
|
|
2259
|
+
}
|