@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.6
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/CHANGELOG.md +33 -0
- package/CHANGELOG_EN.md +33 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +24360 -1436
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +401 -7
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1436 -143
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +59 -0
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +447 -7
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* All copy resolves through the app's locale runtime (`t`), so the panel follows
|
|
48
48
|
* the user's language preference.
|
|
49
49
|
*/
|
|
50
|
-
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
|
|
50
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
|
|
51
51
|
import { createPortal } from 'react-dom'
|
|
52
52
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
53
53
|
import {
|
|
@@ -56,6 +56,22 @@ import {
|
|
|
56
56
|
type Appearance, type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
|
|
57
57
|
} from './themes.ts'
|
|
58
58
|
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
59
|
+
import { parsePatch } from '../patch-model.ts'
|
|
60
|
+
import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
|
|
61
|
+
import { countBlocks, unifiedBlocks } from './diff-nav.ts'
|
|
62
|
+
import { clampPane, neighbourWidth } from './pane-size.ts'
|
|
63
|
+
import { useChangeNav } from './use-change-nav.ts'
|
|
64
|
+
import {
|
|
65
|
+
applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
|
|
66
|
+
LEAVE_GUARD_CLEAR, leaveAnswered, leaveAsked, markConflict, paneDirtyReport, reloadSides, resetSides,
|
|
67
|
+
type EditState, type LeaveGuard, type WriteResult,
|
|
68
|
+
} from './side-edit.ts'
|
|
69
|
+
import { FileBrowser } from './FileBrowser.tsx'
|
|
70
|
+
import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
|
|
71
|
+
import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
|
|
72
|
+
import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
|
|
73
|
+
import { detectIndent } from './indent.ts'
|
|
74
|
+
import { CodeEditor } from './CodeEditor.tsx'
|
|
59
75
|
import { layoutGraph, type GraphRow } from './commit-graph.ts'
|
|
60
76
|
import { formatCommitDate } from './commit-filter.ts'
|
|
61
77
|
import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
|
|
@@ -71,8 +87,8 @@ import {
|
|
|
71
87
|
fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
|
|
72
88
|
type CheckState, type Tick, type TickAction,
|
|
73
89
|
} from './stage-tree.ts'
|
|
74
|
-
import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
75
|
-
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
|
|
90
|
+
import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
91
|
+
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
|
|
76
92
|
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
77
93
|
import type { WorkbenchKey } from './locales.ts'
|
|
78
94
|
import css from './GitWorkbenchPanel.module.css'
|
|
@@ -186,10 +202,12 @@ export interface SyncStatus {
|
|
|
186
202
|
readonly hasRemote: boolean
|
|
187
203
|
}
|
|
188
204
|
|
|
189
|
-
/** Why a write operation failed, in terms the drawer can explain.
|
|
205
|
+
/** Why a write operation failed, in terms the drawer can explain. `stale` is
|
|
206
|
+
* a sha the host re-derived and refused; `invalid` an argument combination
|
|
207
|
+
* the host rejected before running anything. */
|
|
190
208
|
export type GitOpFailure =
|
|
191
209
|
| 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
|
|
192
|
-
| 'nothing-to-commit' | 'dirty' | 'unknown'
|
|
210
|
+
| 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
|
|
193
211
|
|
|
194
212
|
export interface GitOpResult {
|
|
195
213
|
readonly ok: boolean
|
|
@@ -200,32 +218,145 @@ export interface GitOpResult {
|
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
/** The host endpoints under `gitWorkbench/` that change something. */
|
|
203
|
-
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile'
|
|
221
|
+
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
|
|
204
222
|
|
|
205
223
|
/** Extra arguments an operation needs beyond the worktree path. */
|
|
206
224
|
export interface GitOpPayload {
|
|
207
225
|
readonly paths?: readonly string[]
|
|
208
226
|
readonly message?: string
|
|
209
227
|
readonly amend?: boolean
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*
|
|
213
|
-
|
|
228
|
+
/** `pull` picks how to integrate; `applyBlocks` which block mutation. One
|
|
229
|
+
* field serves both because the payload is a flat bag keyed by op — the
|
|
230
|
+
* host narrows and validates it per endpoint. */
|
|
231
|
+
readonly mode?: 'ff-only' | 'rebase' | 'merge' | BlockMode
|
|
232
|
+
/** `discardFile` and `applyBlocks`, and deliberately singular: the one
|
|
233
|
+
* irreversible thing the drawer does takes one file per call, so a mistaken
|
|
234
|
+
* click costs one file. */
|
|
214
235
|
readonly path?: string
|
|
215
236
|
/** `discardFile` only: the effect the confirmation stated. The host refuses
|
|
216
237
|
* if the file changed underneath the dialog and now means something else. */
|
|
217
238
|
readonly expectedEffect?: string
|
|
239
|
+
/** `applyBlocks` only: the layer whose diff the `diffSha` is over, and the
|
|
240
|
+
* block's hunk-line indices (`side-rows.blockLines`). The host re-fetches
|
|
241
|
+
* that layer's diff and refuses unless the sha still matches. */
|
|
242
|
+
readonly layer?: SideLayer
|
|
243
|
+
readonly diffSha?: string
|
|
244
|
+
readonly lines?: readonly number[]
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
|
|
248
|
+
export type { WriteResult } from './side-edit.ts'
|
|
249
|
+
|
|
250
|
+
/** Which side of the index a side-by-side pane shows: `unstaged` is
|
|
251
|
+
* index→worktree (the editable side), `staged` is HEAD→index (read-only). */
|
|
252
|
+
export type SideLayer = 'unstaged' | 'staged'
|
|
253
|
+
|
|
254
|
+
/** A block mutation the side pane's buttons request: `stage` and `discard` act
|
|
255
|
+
* on the unstaged layer, `unstage` on the staged one. The host enforces the
|
|
256
|
+
* same matrix. */
|
|
257
|
+
export type BlockMode = 'stage' | 'unstage' | 'discard'
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What one block action acts on, snapshotted from the diff the pane had
|
|
261
|
+
* rendered when the click (or its confirmation) happened.
|
|
262
|
+
*
|
|
263
|
+
* The snapshot is the point: `diffSha` proves the file has not changed since
|
|
264
|
+
* the pane rendered it, and `lines` — the block's hunk-line indices — only
|
|
265
|
+
* mean anything against exactly that diff. A confirmed roll-back carries the
|
|
266
|
+
* ask it opened with, so the answer cannot drift under the dialog.
|
|
267
|
+
*/
|
|
268
|
+
export interface BlockAsk {
|
|
269
|
+
readonly path: string
|
|
270
|
+
readonly layer: SideLayer
|
|
271
|
+
readonly diffSha: string
|
|
272
|
+
readonly lines: readonly number[]
|
|
273
|
+
/** The block's line tallies, for the roll-back confirmation's wording. */
|
|
274
|
+
readonly added: number
|
|
275
|
+
readonly deleted: number
|
|
276
|
+
/** Whether the block is the file's entire content — the untracked case,
|
|
277
|
+
* whose roll-back DELETES the file and whose confirmation says so. */
|
|
278
|
+
readonly wholeFile: boolean
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `gitWorkbench/fileSides`: one layer of one file for the side-by-side pane.
|
|
283
|
+
* Mirrors the host's `FileSides` (the client re-declares host shapes rather
|
|
284
|
+
* than importing the host module, which pulls node and the RPC decorators).
|
|
285
|
+
*/
|
|
286
|
+
export interface FileSides {
|
|
287
|
+
/** Unified diff at full context; '' when the layer has no change. */
|
|
288
|
+
readonly diff: string
|
|
289
|
+
/** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
|
|
290
|
+
readonly diffSha: string
|
|
291
|
+
/** Whole right-hand text, the editor's initial buffer. */
|
|
292
|
+
readonly targetText: string
|
|
293
|
+
/** Blob sha of the right-hand side; '' when it does not exist. */
|
|
294
|
+
readonly targetSha: string
|
|
295
|
+
readonly binary: boolean
|
|
296
|
+
/** True when the file is past the size guard; the client shows the old view. */
|
|
297
|
+
readonly tooLarge: boolean
|
|
298
|
+
/** True when the working-tree file is not valid UTF-8; the pane shows the
|
|
299
|
+
* diff but withholds the editor. Optional so an older host half reads as
|
|
300
|
+
* "fine" rather than as a refusal this client cannot explain. */
|
|
301
|
+
readonly lossyEncoding?: boolean
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `gitWorkbench/fileImage`: one working-tree file's bytes, when the host's
|
|
306
|
+
* signature check confirms they are an image a browser can draw.
|
|
307
|
+
*
|
|
308
|
+
* Every field is present in both outcomes — an image and a refusal — because
|
|
309
|
+
* the gateway's payloads carry no `undefined`. `reason` is '' exactly when
|
|
310
|
+
* `ok`, and names the refusal otherwise: 'notImage', 'tooLarge', 'missing'.
|
|
311
|
+
*/
|
|
312
|
+
export interface FileImage {
|
|
313
|
+
readonly ok: boolean
|
|
314
|
+
/** MIME type to label the blob with; '' when declined. */
|
|
315
|
+
readonly mime: string
|
|
316
|
+
/** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
|
|
317
|
+
readonly kind: string
|
|
318
|
+
/** The whole file, base64; '' when declined. */
|
|
319
|
+
readonly base64: string
|
|
320
|
+
/** The file's size in bytes, reported either way. */
|
|
321
|
+
readonly bytes: number
|
|
322
|
+
readonly reason: string
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One line's provenance, as `gitWorkbench/blame` reports it. */
|
|
326
|
+
export interface BlameLine {
|
|
327
|
+
/** Full commit sha; all zeros for a line not committed yet. */
|
|
328
|
+
readonly hash: string
|
|
329
|
+
readonly author: string
|
|
330
|
+
/** Author time, unix seconds; 0 when git did not say. */
|
|
331
|
+
readonly time: number
|
|
332
|
+
readonly summary: string
|
|
333
|
+
readonly uncommitted: boolean
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `gitWorkbench/blame`'s answer. `error` is present only on failure. */
|
|
337
|
+
export interface BlameAnswer {
|
|
338
|
+
readonly lines: readonly BlameLine[]
|
|
339
|
+
/** Whether the file was longer than the gutter's cap. */
|
|
340
|
+
readonly truncated: boolean
|
|
341
|
+
readonly error?: string
|
|
342
|
+
}
|
|
221
343
|
|
|
222
344
|
/** Translate a key of this plugin's namespace, with optional `{name}` params. */
|
|
223
|
-
type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
345
|
+
export type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
224
346
|
|
|
225
347
|
type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
226
348
|
readonly t: Translate
|
|
227
349
|
readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
228
350
|
readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
|
|
351
|
+
/** One layer of one file for the side-by-side diff pane. */
|
|
352
|
+
readonly fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
353
|
+
/** Save the editor buffer, checked against the sha it opened with. */
|
|
354
|
+
readonly writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
355
|
+
readonly fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
|
|
356
|
+
/** One file's bytes, when they are an image. Null when the host half is
|
|
357
|
+
* older than this client: the view then falls back to the text answer. */
|
|
358
|
+
readonly fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
|
|
359
|
+
/** Where the symbol at a zero-based protocol position is defined. */
|
|
229
360
|
readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
|
|
230
361
|
/** Binding only, no git — the probe the shut chip can afford to poll. */
|
|
231
362
|
readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
|
|
@@ -252,7 +383,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
|
252
383
|
* modes with a back action, so returning to the working tree is always one click
|
|
253
384
|
* (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
|
|
254
385
|
*/
|
|
255
|
-
type Tab = 'changes' | 'history' | 'compare'
|
|
386
|
+
type Tab = 'changes' | 'history' | 'compare' | 'files'
|
|
256
387
|
|
|
257
388
|
/** How many further commits one page request loads. */
|
|
258
389
|
const HISTORY_PAGE = 30
|
|
@@ -265,6 +396,11 @@ const MIN_DRAWER_WIDTH = 760
|
|
|
265
396
|
const MIN_COMMITS_WIDTH = 190
|
|
266
397
|
const MIN_TREE_WIDTH = 170
|
|
267
398
|
const MIN_DIFF_WIDTH = 300
|
|
399
|
+
/** Floors for the History tab's horizontal split. The list keeps enough for a
|
|
400
|
+
* few rows to read as a list rather than as a strip; the half below keeps
|
|
401
|
+
* enough that the tree and the diff are still worth rendering. */
|
|
402
|
+
const MIN_COMMITS_HEIGHT = 90
|
|
403
|
+
const MIN_STACKED_LOWER = 200
|
|
268
404
|
|
|
269
405
|
/** Longest edge a chosen background image is resampled to before storage. Past
|
|
270
406
|
* this the file grows fast while a blurred backdrop gains nothing. */
|
|
@@ -283,14 +419,31 @@ const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
|
|
|
283
419
|
const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
|
|
284
420
|
const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
|
|
285
421
|
const STORE_PANES = 'dsh-ui-gitworkbench:panes'
|
|
422
|
+
/** Where the reader was in the Files tab, per worktree. View state, so it
|
|
423
|
+
* belongs here beside the layout rather than in the host's per-project store:
|
|
424
|
+
* two people on one repository should not share each other's place. */
|
|
425
|
+
const STORE_FILES = 'dsh-ui-gitworkbench:files'
|
|
286
426
|
|
|
287
|
-
/** Dragged pane
|
|
427
|
+
/** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
|
|
288
428
|
interface PaneWidths {
|
|
289
429
|
readonly commits: number | null
|
|
290
430
|
readonly tree: number | null
|
|
431
|
+
/**
|
|
432
|
+
* The commit list's HEIGHT, which is what the History tab's divider moves
|
|
433
|
+
* now that the list spans the drawer's full width.
|
|
434
|
+
*
|
|
435
|
+
* Optional because storage is a durable boundary: values written before the
|
|
436
|
+
* tab was stacked have no such field, and rejecting them outright would
|
|
437
|
+
* throw away the column widths the reader had already chosen.
|
|
438
|
+
*/
|
|
439
|
+
readonly commitsTall?: number | null
|
|
291
440
|
}
|
|
292
441
|
|
|
293
|
-
|
|
442
|
+
/** The two panes whose WIDTH a divider drags. `commitsTall` is deliberately
|
|
443
|
+
* not one of them — it is a height, with its own floors. */
|
|
444
|
+
type PaneWidthKey = 'commits' | 'tree'
|
|
445
|
+
|
|
446
|
+
const DEFAULT_PANES: PaneWidths = { commits: null, tree: null, commitsTall: null }
|
|
294
447
|
|
|
295
448
|
/**
|
|
296
449
|
* @param value - value read back from storage.
|
|
@@ -298,9 +451,9 @@ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
|
|
|
298
451
|
*/
|
|
299
452
|
function isPaneWidths(value: unknown): value is PaneWidths {
|
|
300
453
|
if (typeof value !== 'object' || value === null) return false
|
|
301
|
-
const { commits, tree } = value as Partial<PaneWidths>
|
|
454
|
+
const { commits, tree, commitsTall } = value as Partial<PaneWidths>
|
|
302
455
|
const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
|
|
303
|
-
return ok(commits) && ok(tree)
|
|
456
|
+
return ok(commits) && ok(tree) && (commitsTall === undefined || ok(commitsTall))
|
|
304
457
|
}
|
|
305
458
|
|
|
306
459
|
/**
|
|
@@ -349,24 +502,26 @@ function writeStored(key: string, value: unknown): void {
|
|
|
349
502
|
* crosses a child that stops propagation.
|
|
350
503
|
* @returns the active flag for styling, and the pointerdown handler to attach.
|
|
351
504
|
*/
|
|
352
|
-
function
|
|
505
|
+
function usePaneDrag(axis: 'x' | 'y' = 'x'): {
|
|
353
506
|
dragging: boolean
|
|
354
|
-
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (
|
|
507
|
+
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void) => void
|
|
355
508
|
} {
|
|
509
|
+
const along = (event: { clientX: number; clientY: number }): number =>
|
|
510
|
+
axis === 'y' ? event.clientY : event.clientX
|
|
356
511
|
const [dragging, setDragging] = useState(false)
|
|
357
|
-
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (
|
|
512
|
+
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void): void => {
|
|
358
513
|
event.preventDefault()
|
|
359
514
|
const handle = event.currentTarget
|
|
360
515
|
handle.setPointerCapture(event.pointerId)
|
|
361
516
|
setDragging(true)
|
|
362
|
-
const onMove = (move: PointerEvent): void => { onDrag(move
|
|
517
|
+
const onMove = (move: PointerEvent): void => { onDrag(along(move), false) }
|
|
363
518
|
// `pointercancel` ends a drag the browser took over (a touch became a
|
|
364
519
|
// gesture, the window lost focus). It releases capture itself, so only the
|
|
365
520
|
// pointerup path releases — and both must detach, or the next drag stacks a
|
|
366
521
|
// second set of listeners on the same handle.
|
|
367
522
|
const finish = (end: PointerEvent): void => {
|
|
368
523
|
if (end.type === 'pointerup') {
|
|
369
|
-
onDrag(end
|
|
524
|
+
onDrag(along(end), true)
|
|
370
525
|
handle.releasePointerCapture(end.pointerId)
|
|
371
526
|
}
|
|
372
527
|
handle.removeEventListener('pointermove', onMove)
|
|
@@ -387,22 +542,34 @@ function useHorizontalDrag(): {
|
|
|
387
542
|
* @param onDrag - receives the pointer's x and whether the drag just ended.
|
|
388
543
|
* @returns the divider element.
|
|
389
544
|
*/
|
|
390
|
-
function PaneDivider({ label, onDrag }: {
|
|
545
|
+
function PaneDivider({ label, onDrag, axis = 'x' }: {
|
|
391
546
|
label: string
|
|
392
|
-
|
|
547
|
+
/** clientX for an 'x' handle, clientY for a 'y' one. */
|
|
548
|
+
onDrag: (position: number, done: boolean) => void
|
|
549
|
+
/** Which way the handle moves. 'y' is the History tab's stacked split. */
|
|
550
|
+
axis?: 'x' | 'y'
|
|
393
551
|
}): ReactNode {
|
|
394
|
-
const { dragging, start } =
|
|
552
|
+
const { dragging, start } = usePaneDrag(axis)
|
|
553
|
+
const base = axis === 'y' ? `${css.paneDivider} ${css.paneDividerY}` : css.paneDivider
|
|
395
554
|
return (
|
|
396
555
|
<div
|
|
397
|
-
className={dragging ? `${
|
|
556
|
+
className={dragging ? `${base} ${css.paneDividerActive}` : base}
|
|
398
557
|
role="separator"
|
|
399
|
-
|
|
558
|
+
// The orientation a separator reports is the axis it SEPARATES along,
|
|
559
|
+
// which is the opposite of the one it slides on.
|
|
560
|
+
aria-orientation={axis === 'y' ? 'horizontal' : 'vertical'}
|
|
400
561
|
aria-label={label}
|
|
401
562
|
onPointerDown={event => start(event, onDrag)}
|
|
402
563
|
/>
|
|
403
564
|
)
|
|
404
565
|
}
|
|
405
566
|
|
|
567
|
+
/** How narrow either side-by-side column may be dragged. A column pulled to
|
|
568
|
+
* nothing reads as a broken pane, and nothing on screen offers to pull it
|
|
569
|
+
* back out. */
|
|
570
|
+
const SPLIT_MIN = 0.15
|
|
571
|
+
const SPLIT_MAX = 0.85
|
|
572
|
+
|
|
406
573
|
/** Commit change sets kept in the browser before the least recently used is dropped. */
|
|
407
574
|
const COMMIT_CACHE_CAPACITY = 24
|
|
408
575
|
|
|
@@ -413,6 +580,19 @@ const EMPTY_STATS: WorkbenchStats = {
|
|
|
413
580
|
files: [], diff: '', commits: [],
|
|
414
581
|
}
|
|
415
582
|
|
|
583
|
+
/** How long the Files place must hold still before it is written. */
|
|
584
|
+
const PLACES_WRITE_MS = 500
|
|
585
|
+
|
|
586
|
+
/** One worktree's last-read file list. */
|
|
587
|
+
interface FilesTree {
|
|
588
|
+
readonly paths: readonly string[]
|
|
589
|
+
readonly truncated: boolean
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** A worktree nobody has opened the Files tab on yet. One instance, so an
|
|
593
|
+
* unvisited worktree does not re-render the browser on every pass. */
|
|
594
|
+
const EMPTY_TREE: FilesTree = { paths: [], truncated: false }
|
|
595
|
+
|
|
416
596
|
/** The overlay with nothing on it — one instance, so an empty overlay never
|
|
417
597
|
* re-renders the tree that receives it. */
|
|
418
598
|
const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
|
|
@@ -448,7 +628,7 @@ const STATUS_BADGE: Record<GitFileStatus, string> = {
|
|
|
448
628
|
renamed: css.stRenamed, deleted: css.stDeleted,
|
|
449
629
|
}
|
|
450
630
|
|
|
451
|
-
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
|
|
631
|
+
export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
|
|
452
632
|
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
453
633
|
state?.byId?.[sessionId]?.cwd) as string | undefined
|
|
454
634
|
/** Whether the session's agent has a turn in flight — the store mirrors it
|
|
@@ -467,6 +647,34 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
467
647
|
const [gen, setGen] = useState(0)
|
|
468
648
|
/** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
|
|
469
649
|
const [collapsed, setCollapsed] = useState<Set<string> | undefined>(undefined)
|
|
650
|
+
// The Files tab's place and its last file list, ONE PER WORKTREE. Held here
|
|
651
|
+
// rather than in the browser because the browser unmounts whenever another
|
|
652
|
+
// tab is looked at: without this, coming back lost the selection and every
|
|
653
|
+
// expanded folder, which is the difference between a tab you return to and
|
|
654
|
+
// one you start over in.
|
|
655
|
+
//
|
|
656
|
+
// Per worktree because a worktree is a different place — different files, at
|
|
657
|
+
// different paths, and the open one may not exist in the next one at all.
|
|
658
|
+
// Keying the cached list too is what stops a switch from rendering the
|
|
659
|
+
// previous worktree's files until the new list arrives.
|
|
660
|
+
const [filesPlaces, setFilesPlaces] = useState<FilesPlaces>(
|
|
661
|
+
() => decodePlaces(readStored<unknown>(STORE_FILES, (value): value is unknown => true, null)),
|
|
662
|
+
)
|
|
663
|
+
const [filesTrees, setFilesTrees] = useState<ReadonlyMap<string, FilesTree>>(() => new Map())
|
|
664
|
+
const rememberPlace = useCallback((key: string, next: FilesPlace): void => {
|
|
665
|
+
setFilesPlaces(prev => withPlace(prev, key, next))
|
|
666
|
+
}, [])
|
|
667
|
+
const rememberTree = useCallback((key: string, next: FilesTree): void => {
|
|
668
|
+
setFilesTrees(prev => {
|
|
669
|
+
const map = new Map(prev)
|
|
670
|
+
map.set(key, next)
|
|
671
|
+
return map
|
|
672
|
+
})
|
|
673
|
+
}, [])
|
|
674
|
+
// Written on a pause, not on the change: expanding a folder and typing in
|
|
675
|
+
// the search box both move the place, and localStorage writes synchronously.
|
|
676
|
+
const settledPlaces = useIdleValue(filesPlaces, PLACES_WRITE_MS)
|
|
677
|
+
useEffect(() => { writeStored(STORE_FILES, encodePlaces(settledPlaces)) }, [settledPlaces])
|
|
470
678
|
/** Binding + the repository's worktrees, null until the first successful fetch. */
|
|
471
679
|
const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
|
|
472
680
|
/** Worktree the drawer reads, by absolute path. null = follow the session's own
|
|
@@ -837,8 +1045,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
837
1045
|
setPendingTicks(EMPTY_TICKS)
|
|
838
1046
|
setStats({
|
|
839
1047
|
...EMPTY_STATS,
|
|
840
|
-
|
|
841
|
-
|
|
1048
|
+
// No source pinned and no session binding yet: the empty path is what
|
|
1049
|
+
// EMPTY_STATS already means by "nowhere", not a missing value.
|
|
1050
|
+
worktreePath: statsPath ?? '',
|
|
1051
|
+
branch: branchOfWorktree(statsPath, worktreesRef.current) ?? '',
|
|
842
1052
|
})
|
|
843
1053
|
}, [statsPath])
|
|
844
1054
|
|
|
@@ -854,8 +1064,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
854
1064
|
const prevBinding = lastBindingRef.current
|
|
855
1065
|
lastBindingRef.current = bindingPath
|
|
856
1066
|
if (prevBinding === bindingPath) return
|
|
857
|
-
const prevSource = prevBinding ?? worktreePath
|
|
858
|
-
if (sourcePath !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
|
|
1067
|
+
const prevSource = prevBinding ?? worktreePath ?? null
|
|
1068
|
+
if (sourcePath !== null && prevSource !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
|
|
859
1069
|
setSourcePath(null)
|
|
860
1070
|
}
|
|
861
1071
|
}, [bindingPath, sourcePath, worktreePath])
|
|
@@ -1014,9 +1224,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1014
1224
|
// made the drawer shake on every tick — and a tick's outcome is already
|
|
1015
1225
|
// visible in place, in the box the user just clicked. So: the old banner
|
|
1016
1226
|
// stays while the op runs (it still describes the last outcome), a
|
|
1017
|
-
// successful stage/unstage
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1227
|
+
// successful stage/unstage — tick or block — clears it rather than
|
|
1228
|
+
// replacing it (the block visibly leaves its layer on the refetch), and
|
|
1229
|
+
// only heavy operations and failures announce themselves at all.
|
|
1230
|
+
if (result.ok && (op === 'stage' || op === 'unstage' || op === 'applyBlocks')) setOpResult(null)
|
|
1020
1231
|
else setOpResult({ op, result })
|
|
1021
1232
|
return result
|
|
1022
1233
|
} finally {
|
|
@@ -1172,11 +1383,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1172
1383
|
* @param measured - the drawer's inner width and the panes' current widths.
|
|
1173
1384
|
* @param persist - whether to store it; false for intermediate drag frames.
|
|
1174
1385
|
*/
|
|
1175
|
-
const applyPane = (which:
|
|
1386
|
+
const applyPane = (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
|
|
1176
1387
|
const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
|
|
1177
1388
|
const other = which === 'commits' ? measured.tree : measured.commits
|
|
1178
|
-
const
|
|
1179
|
-
const clamped = Math.min(Math.max(next, min), max)
|
|
1389
|
+
const clamped = clampPane(next, min, measured.drawer, other, MIN_DIFF_WIDTH)
|
|
1180
1390
|
setPanes(prev => {
|
|
1181
1391
|
const updated = { ...prev, [which]: clamped }
|
|
1182
1392
|
if (persist) writeStored(STORE_PANES, updated)
|
|
@@ -1184,6 +1394,29 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1184
1394
|
})
|
|
1185
1395
|
}
|
|
1186
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* Drag the History tab's horizontal split.
|
|
1399
|
+
*
|
|
1400
|
+
* Clamped the same way `applyPane` clamps a width, and for the same reason:
|
|
1401
|
+
* a pane dragged to nothing reads as broken and offers nothing to drag back
|
|
1402
|
+
* out. The outer `Math.max` keeps the range from inverting in a drawer too
|
|
1403
|
+
* short to hold both floors — the list then simply takes its minimum.
|
|
1404
|
+
*
|
|
1405
|
+
* @param next - the height in px the pointer implies.
|
|
1406
|
+
* @param bodyHeight - the space the two stacked halves share.
|
|
1407
|
+
* @param persist - false for every intermediate frame of a drag, so a
|
|
1408
|
+
* synchronous localStorage write never lands mid-resize.
|
|
1409
|
+
*/
|
|
1410
|
+
const applyCommitsTall = (next: number, bodyHeight: number, persist: boolean): void => {
|
|
1411
|
+
const max = Math.max(MIN_COMMITS_HEIGHT, bodyHeight - MIN_STACKED_LOWER)
|
|
1412
|
+
const clamped = Math.min(Math.max(next, MIN_COMMITS_HEIGHT), max)
|
|
1413
|
+
setPanes(prev => {
|
|
1414
|
+
const updated = { ...prev, commitsTall: clamped }
|
|
1415
|
+
if (persist) writeStored(STORE_PANES, updated)
|
|
1416
|
+
return updated
|
|
1417
|
+
})
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1187
1420
|
/**
|
|
1188
1421
|
* Drag the leading edge. Clamped at both ends — below the minimum the three
|
|
1189
1422
|
* panes stop fitting, and past the viewport there is nothing to reveal. The
|
|
@@ -1300,6 +1533,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1300
1533
|
onWidth={applyWidth}
|
|
1301
1534
|
panes={panes}
|
|
1302
1535
|
onPane={applyPane}
|
|
1536
|
+
onCommitsTall={applyCommitsTall}
|
|
1303
1537
|
onClose={() => setOpen(false)}
|
|
1304
1538
|
onRefresh={refresh}
|
|
1305
1539
|
commitDraft={commitDraft}
|
|
@@ -1315,9 +1549,17 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1315
1549
|
pendingTicks={pendingTicks}
|
|
1316
1550
|
onTick={queueTicks}
|
|
1317
1551
|
fetchFileDiff={fetchDiffForView}
|
|
1552
|
+
fetchFileSides={fetchFileSides}
|
|
1553
|
+
writeChecked={writeChecked}
|
|
1554
|
+
fetchBlame={fetchBlame}
|
|
1555
|
+
fetchFileImage={fetchFileImage}
|
|
1318
1556
|
viewKey={viewKey}
|
|
1319
1557
|
gen={gen}
|
|
1320
1558
|
collapsed={collapsed}
|
|
1559
|
+
filesPlaces={filesPlaces}
|
|
1560
|
+
onFilesPlace={rememberPlace}
|
|
1561
|
+
filesTrees={filesTrees}
|
|
1562
|
+
onFilesTree={rememberTree}
|
|
1321
1563
|
onCollapsedChange={setCollapsed}
|
|
1322
1564
|
/>
|
|
1323
1565
|
) : null}
|
|
@@ -1353,6 +1595,26 @@ function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
|
1353
1595
|
)
|
|
1354
1596
|
}
|
|
1355
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* Change-to-change navigation, as two chevrons.
|
|
1600
|
+
*
|
|
1601
|
+
* Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
|
|
1602
|
+
* every editor spells this with, and the words would be longer than the
|
|
1603
|
+
* controls beside them.
|
|
1604
|
+
*/
|
|
1605
|
+
const NAV_GLYPH = {
|
|
1606
|
+
prev: 'M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z',
|
|
1607
|
+
next: 'M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z',
|
|
1608
|
+
} as const
|
|
1609
|
+
|
|
1610
|
+
function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
|
|
1611
|
+
return (
|
|
1612
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
1613
|
+
<path d={NAV_GLYPH[of]} />
|
|
1614
|
+
</svg>
|
|
1615
|
+
)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1356
1618
|
/**
|
|
1357
1619
|
* Tree glyph: a root with two working copies hanging off it.
|
|
1358
1620
|
*
|
|
@@ -1482,7 +1744,9 @@ interface DrawerProps {
|
|
|
1482
1744
|
onWidth: (next: number, persist: boolean) => void
|
|
1483
1745
|
/** Dragged pane widths; null on either side keeps that pane's CSS default. */
|
|
1484
1746
|
panes: PaneWidths
|
|
1485
|
-
onPane: (which:
|
|
1747
|
+
onPane: (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
|
|
1748
|
+
/** Drag the History tab's horizontal split: the commit list's height in px. */
|
|
1749
|
+
onCommitsTall: (next: number, bodyHeight: number, persist: boolean) => void
|
|
1486
1750
|
onClose: () => void
|
|
1487
1751
|
onRefresh: () => void
|
|
1488
1752
|
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
@@ -1512,14 +1776,24 @@ interface DrawerProps {
|
|
|
1512
1776
|
/** Queue the git calls for a tick batch. */
|
|
1513
1777
|
onTick: (action: TickAction, paths: readonly string[]) => void
|
|
1514
1778
|
fetchFileDiff: (path: string, signal: AbortSignal) => Promise<string>
|
|
1779
|
+
/** One layer of one file for the side-by-side pane; the drawer binds the source. */
|
|
1780
|
+
fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
1781
|
+
/** Save the side pane's editor buffer; the drawer binds the source. */
|
|
1782
|
+
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
1783
|
+
fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
|
|
1784
|
+
fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
|
|
1515
1785
|
/** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
|
|
1516
1786
|
viewKey: string
|
|
1517
1787
|
gen: number
|
|
1518
1788
|
collapsed: Set<string> | undefined
|
|
1789
|
+
filesPlaces: FilesPlaces
|
|
1790
|
+
onFilesPlace: (key: string, next: FilesPlace) => void
|
|
1791
|
+
filesTrees: ReadonlyMap<string, FilesTree>
|
|
1792
|
+
onFilesTree: (key: string, next: FilesTree) => void
|
|
1519
1793
|
onCollapsedChange: (next: Set<string>) => void
|
|
1520
1794
|
}
|
|
1521
1795
|
|
|
1522
|
-
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
|
|
1796
|
+
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onCommitsTall, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
|
|
1523
1797
|
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1524
1798
|
// stable shape and the panes simply render nothing.
|
|
1525
1799
|
const body = shown ?? EMPTY_STATS
|
|
@@ -1540,6 +1814,20 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1540
1814
|
() => tab === 'history' ? parseLogQuery(historyQuery).paths : NO_PATHS,
|
|
1541
1815
|
[tab, historyQuery],
|
|
1542
1816
|
)
|
|
1817
|
+
/** Working-tree files the browser can open on top of what `repoTree` knows:
|
|
1818
|
+
* `git ls-tree HEAD` cannot see an untracked file, and a browser that will
|
|
1819
|
+
* not open the file you just created reads as broken. A deleted file is
|
|
1820
|
+
* left out — opening it would only fail. */
|
|
1821
|
+
/** Which worktree the Files tab is remembering for. */
|
|
1822
|
+
const filesKey = pathKey(statsPath)
|
|
1823
|
+
const rememberPlaceHere = useCallback(
|
|
1824
|
+
(next: FilesPlace) => { onFilesPlace(filesKey, next) }, [onFilesPlace, filesKey])
|
|
1825
|
+
const rememberTreeHere = useCallback(
|
|
1826
|
+
(next: FilesTree) => { onFilesTree(filesKey, next) }, [onFilesTree, filesKey])
|
|
1827
|
+
const browsablePaths = useMemo(
|
|
1828
|
+
() => stats.files.filter(file => file.status !== 'deleted').map(file => file.path),
|
|
1829
|
+
[stats.files],
|
|
1830
|
+
)
|
|
1543
1831
|
// A selection the current source no longer lists (e.g. after a source or tab
|
|
1544
1832
|
// switch) falls back to the filtered file, else the first — never a dangling
|
|
1545
1833
|
// highlight. See `active-file.ts` for the order and the reasoning.
|
|
@@ -1576,7 +1864,55 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1576
1864
|
// Reset the on-demand cache when the generation (refresh) advances.
|
|
1577
1865
|
useEffect(() => { setFetched(new Map()) }, [gen])
|
|
1578
1866
|
|
|
1579
|
-
|
|
1867
|
+
// The side pane's dirty flag, reported upward: every gesture that would
|
|
1868
|
+
// drop the editor's buffer asks before it acts, and the layer tab is only
|
|
1869
|
+
// the rarest of them — clicking another file in the tree is this pane's
|
|
1870
|
+
// PRIMARY navigation. The guard is the little state machine in
|
|
1871
|
+
// side-edit.ts (gateLeave / leaveAsked / leaveAnswered / paneDirtyReport);
|
|
1872
|
+
// the PANE is the dirty flag's only writer, so the drawer never guesses
|
|
1873
|
+
// it — clearing the flag on a confirmed Leave is what let a no-op gesture
|
|
1874
|
+
// (the already-active tab, the already-shown file's row) disarm the guard
|
|
1875
|
+
// for every gesture after it.
|
|
1876
|
+
const [leaveGuard, setLeaveGuard] = useState<LeaveGuard>(LEAVE_GUARD_CLEAR)
|
|
1877
|
+
const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null)
|
|
1878
|
+
const onSideDirty = useCallback((dirty: boolean): void => {
|
|
1879
|
+
setLeaveGuard(prev => paneDirtyReport(prev, dirty))
|
|
1880
|
+
}, [])
|
|
1881
|
+
const guardLeave = (act: () => void, same: boolean): void => {
|
|
1882
|
+
const gate = gateLeave(leaveGuard, same)
|
|
1883
|
+
if (gate.kind === 'wait') return
|
|
1884
|
+
if (gate.kind === 'ask') {
|
|
1885
|
+
setPendingLeave(() => act)
|
|
1886
|
+
setLeaveGuard(leaveAsked)
|
|
1887
|
+
return
|
|
1888
|
+
}
|
|
1889
|
+
act()
|
|
1890
|
+
}
|
|
1891
|
+
const settleLeaveAsk = (): void => {
|
|
1892
|
+
setPendingLeave(null)
|
|
1893
|
+
setLeaveGuard(leaveAnswered)
|
|
1894
|
+
}
|
|
1895
|
+
const confirmDrawerLeave = (): void => {
|
|
1896
|
+
const act = pendingLeave
|
|
1897
|
+
if (act === null) return
|
|
1898
|
+
// Leave closes the ask and runs the gesture; the flag keeps the pane's
|
|
1899
|
+
// last report. A real navigation's reset reports clean on its own; a
|
|
1900
|
+
// no-op gesture never should have prompted, and its Leave leaves the
|
|
1901
|
+
// guard armed.
|
|
1902
|
+
settleLeaveAsk()
|
|
1903
|
+
act()
|
|
1904
|
+
}
|
|
1905
|
+
const closeDrawer = (): void => guardLeave(onClose, false)
|
|
1906
|
+
const leaveTab = (next: Tab): void => guardLeave(() => onSwitchTab(next), next === tab)
|
|
1907
|
+
// `active`, not `selected`: the pane's identity is the file it SHOWS, and
|
|
1908
|
+
// the preferred-file fallback can leave `selected` naming a file the pane
|
|
1909
|
+
// is not rendering — the row that changes nothing is the shown file's.
|
|
1910
|
+
const selectAndReveal = (path: string): void => guardLeave(() => onSelect(path), path === active)
|
|
1911
|
+
// The source picker swaps the whole worktree under the drawer — the buffer
|
|
1912
|
+
// belongs to a file the new source may not even list. Picking the source
|
|
1913
|
+
// already on screen changes nothing, so it runs unguarded like every other
|
|
1914
|
+
// no-op gesture.
|
|
1915
|
+
const leaveSource = (next: string): void => guardLeave(() => onSwitchSource(next), samePath(next, statsPath))
|
|
1580
1916
|
|
|
1581
1917
|
/**
|
|
1582
1918
|
* Roll-back, in two steps that are deliberately not one.
|
|
@@ -1618,10 +1954,43 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1618
1954
|
void runOp('discardFile', { path: pending.file.path, expectedEffect: pending.plan.effect })
|
|
1619
1955
|
}
|
|
1620
1956
|
|
|
1957
|
+
/** The BLOCK roll-back being asked about, snapshotted at click time; null
|
|
1958
|
+
* while none is open. The confirmation states this ask, and the confirmed
|
|
1959
|
+
* call carries it verbatim — so if the file moves underneath the dialog,
|
|
1960
|
+
* the host's diffSha refusal is what stops the apply, not a re-derived
|
|
1961
|
+
* (different) block. */
|
|
1962
|
+
const [blockDiscard, setBlockDiscard] = useState<BlockAsk | null>(null)
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* One block action from the side pane, routed the way `askDiscard` routes a
|
|
1966
|
+
* file's: stage and unstage are not destructive and run now, through the same
|
|
1967
|
+
* op machinery as every tick; discard is the irreversible one, so its click
|
|
1968
|
+
* only asks. The chain has no plan RPC to call — the consequence of reverting
|
|
1969
|
+
* THIS block's lines is fully stated by the pane's own rows — and the
|
|
1970
|
+
* "refuse if the answer changed" step is the host's stale check, which fires
|
|
1971
|
+
* on the diffSha the dialog was opened against.
|
|
1972
|
+
*/
|
|
1973
|
+
const askBlockAction = (mode: BlockMode, ask: BlockAsk): Promise<GitOpResult> => {
|
|
1974
|
+
if (mode === 'discard') {
|
|
1975
|
+
setBlockDiscard(ask)
|
|
1976
|
+
return Promise.resolve({ ok: true })
|
|
1977
|
+
}
|
|
1978
|
+
return runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode })
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const confirmBlockDiscard = (): void => {
|
|
1982
|
+
const ask = blockDiscard
|
|
1983
|
+
if (ask === null) return
|
|
1984
|
+
setBlockDiscard(null)
|
|
1985
|
+
void runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode: 'discard' })
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1621
1988
|
const drawerRef = useRef<HTMLDivElement>(null)
|
|
1989
|
+
/** The two stacked halves' shared box, which the History split measures in. */
|
|
1990
|
+
const bodyRef = useRef<HTMLDivElement>(null)
|
|
1622
1991
|
const commitsRef = useRef<HTMLDivElement>(null)
|
|
1623
1992
|
const treeRef = useRef<HTMLDivElement>(null)
|
|
1624
|
-
const edgeDrag =
|
|
1993
|
+
const edgeDrag = usePaneDrag()
|
|
1625
1994
|
|
|
1626
1995
|
/**
|
|
1627
1996
|
* What a pane drag is clamped against: the drawer's inner width and what the
|
|
@@ -1629,18 +1998,27 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1629
1998
|
* resized since the last render.
|
|
1630
1999
|
* @returns the three widths in px.
|
|
1631
2000
|
*/
|
|
1632
|
-
const measurePanes = (): { drawer: number; commits: number; tree: number } =>
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
2001
|
+
const measurePanes = (): { drawer: number; commits: number; tree: number } => {
|
|
2002
|
+
const commits = commitsRef.current?.getBoundingClientRect() ?? null
|
|
2003
|
+
const tree = treeRef.current?.getBoundingClientRect() ?? null
|
|
2004
|
+
return {
|
|
2005
|
+
drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
|
|
2006
|
+
// Each width counts only where the pane is actually IN THE WAY of the
|
|
2007
|
+
// other. Stacked, the commit list spans the drawer above the row rather
|
|
2008
|
+
// than sitting beside the tree, and taking its width as the tree's
|
|
2009
|
+
// neighbour made the ceiling smaller than the floor — the tree stayed at
|
|
2010
|
+
// its minimum whatever the pointer did.
|
|
2011
|
+
commits: neighbourWidth(commits, tree),
|
|
2012
|
+
tree: neighbourWidth(tree, commits),
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1637
2015
|
|
|
1638
2016
|
/**
|
|
1639
2017
|
* @param which - the pane a divider resizes.
|
|
1640
2018
|
* @param ref - that pane's element, whose left edge the width is measured from.
|
|
1641
2019
|
* @returns a drag handler for {@link PaneDivider}.
|
|
1642
2020
|
*/
|
|
1643
|
-
const paneDrag = (which:
|
|
2021
|
+
const paneDrag = (which: PaneWidthKey, ref: { current: HTMLDivElement | null }) =>
|
|
1644
2022
|
(clientX: number, done: boolean): void => {
|
|
1645
2023
|
const left = ref.current?.getBoundingClientRect().left
|
|
1646
2024
|
if (left === undefined) return
|
|
@@ -1660,6 +2038,34 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1660
2038
|
const paneStyle = (px: number | null): CSSProperties | undefined =>
|
|
1661
2039
|
px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
|
|
1662
2040
|
|
|
2041
|
+
/**
|
|
2042
|
+
* The History tab's commit list, sized by height instead. `flex: none` so
|
|
2043
|
+
* the height is the height — a flex child in a column would otherwise
|
|
2044
|
+
* stretch or shrink away from it.
|
|
2045
|
+
*
|
|
2046
|
+
* The floor and the ceiling are NOT applied here. A stored height is a
|
|
2047
|
+
* number of pixels, and the drag that produced it clamped against the body
|
|
2048
|
+
* as it stood at that moment; the window can be made shorter afterwards,
|
|
2049
|
+
* and then a height that was reasonable becomes taller than everything.
|
|
2050
|
+
* The lower half collapses to nothing and the handle is pushed past the
|
|
2051
|
+
* bottom edge — there is no longer anything on screen to drag back, which
|
|
2052
|
+
* is the one failure mode a resizable split must not have. So the clamp
|
|
2053
|
+
* lives in the stylesheet, against `100%` of whatever the body currently
|
|
2054
|
+
* is, from the same two constants the drag clamp uses. The reader's chosen
|
|
2055
|
+
* height is kept, not rewritten: make the window tall again and it returns.
|
|
2056
|
+
*/
|
|
2057
|
+
const paneTall = (px: number | null): CSSProperties | undefined =>
|
|
2058
|
+
px === null ? undefined : { height: `${px}px`, flex: 'none' }
|
|
2059
|
+
|
|
2060
|
+
/** The stacked split, measured from the top of the body rather than from the
|
|
2061
|
+
* list: the list's own top is what the drag is moving the bottom of, and
|
|
2062
|
+
* measuring from a moving edge makes the handle drift under the pointer. */
|
|
2063
|
+
const commitsTallDrag = (clientY: number, done: boolean): void => {
|
|
2064
|
+
const box = bodyRef.current?.getBoundingClientRect()
|
|
2065
|
+
if (box === undefined) return
|
|
2066
|
+
onCommitsTall(clientY - box.top, box.height, done)
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1663
2069
|
// Width and the background's three tunables are inline because both are live
|
|
1664
2070
|
// user values; the stylesheet only says what reads them. The pane floors are
|
|
1665
2071
|
// inline for a different reason: they belong to the drag clamp above, and
|
|
@@ -1671,6 +2077,8 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1671
2077
|
'--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
|
|
1672
2078
|
'--gs-min-tree': `${MIN_TREE_WIDTH}px`,
|
|
1673
2079
|
'--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
|
|
2080
|
+
'--gs-min-commits-tall': `${MIN_COMMITS_HEIGHT}px`,
|
|
2081
|
+
'--gs-min-stacked-lower': `${MIN_STACKED_LOWER}px`,
|
|
1674
2082
|
} as CSSProperties,
|
|
1675
2083
|
...maximized || width === null ? {} : { width: `${width}px` },
|
|
1676
2084
|
...background === null ? {} : {
|
|
@@ -1685,7 +2093,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1685
2093
|
className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
|
|
1686
2094
|
data-gs-theme={theme}
|
|
1687
2095
|
data-gs-part="overlay"
|
|
1688
|
-
onClick={
|
|
2096
|
+
onClick={closeDrawer}
|
|
1689
2097
|
>
|
|
1690
2098
|
<div
|
|
1691
2099
|
ref={drawerRef}
|
|
@@ -1717,7 +2125,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1717
2125
|
sessionPath={sessionPath}
|
|
1718
2126
|
statsPath={statsPath}
|
|
1719
2127
|
fallbackBranch={stats.branch}
|
|
1720
|
-
onSwitch={
|
|
2128
|
+
onSwitch={leaveSource}
|
|
1721
2129
|
/>
|
|
1722
2130
|
<Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
|
|
1723
2131
|
{tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
|
|
@@ -1776,7 +2184,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1776
2184
|
type="button"
|
|
1777
2185
|
className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
|
|
1778
2186
|
aria-label={t('close')} title={t('close')}
|
|
1779
|
-
onClick={
|
|
2187
|
+
onClick={closeDrawer}
|
|
1780
2188
|
><ChromeGlyph of="close" /></button>
|
|
1781
2189
|
</div>
|
|
1782
2190
|
</div>
|
|
@@ -1786,22 +2194,29 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1786
2194
|
role="tab"
|
|
1787
2195
|
aria-selected={tab === 'changes'}
|
|
1788
2196
|
className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1789
|
-
onClick={() =>
|
|
2197
|
+
onClick={() => leaveTab('changes')}
|
|
1790
2198
|
>{t('tabChanges')}</button>
|
|
1791
2199
|
<button
|
|
1792
2200
|
type="button"
|
|
1793
2201
|
role="tab"
|
|
1794
2202
|
aria-selected={tab === 'history'}
|
|
1795
2203
|
className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1796
|
-
onClick={() =>
|
|
2204
|
+
onClick={() => leaveTab('history')}
|
|
1797
2205
|
>{t('tabHistory')}</button>
|
|
1798
2206
|
<button
|
|
1799
2207
|
type="button"
|
|
1800
2208
|
role="tab"
|
|
1801
2209
|
aria-selected={tab === 'compare'}
|
|
1802
2210
|
className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1803
|
-
onClick={() =>
|
|
2211
|
+
onClick={() => leaveTab('compare')}
|
|
1804
2212
|
>{t('tabCompare')}</button>
|
|
2213
|
+
<button
|
|
2214
|
+
type="button"
|
|
2215
|
+
role="tab"
|
|
2216
|
+
aria-selected={tab === 'files'}
|
|
2217
|
+
className={tab === 'files' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
2218
|
+
onClick={() => leaveTab('files')}
|
|
2219
|
+
>{t('tabFiles')}</button>
|
|
1805
2220
|
</div>
|
|
1806
2221
|
{tab === 'compare' ? (
|
|
1807
2222
|
<CompareBar
|
|
@@ -1837,12 +2252,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1837
2252
|
role="status"
|
|
1838
2253
|
>{opMessage(t, opResult.op, opResult.result)}</div>
|
|
1839
2254
|
) : null}
|
|
1840
|
-
|
|
2255
|
+
{/* History stacks: the commit list across the top, the tree and diff
|
|
2256
|
+
below it. Three columns left every one of them too narrow — the
|
|
2257
|
+
subjects, which are the reason to read a log at all, were the part
|
|
2258
|
+
that gave way — and the drawer's total width is fixed, so dragging
|
|
2259
|
+
a divider could only move the shortage somewhere else. Stacked, the
|
|
2260
|
+
subject gets the whole drawer, and the lower half becomes exactly
|
|
2261
|
+
the Changes tab's layout: one arrangement to learn, not two. */}
|
|
2262
|
+
<div ref={bodyRef} className={css.body} data-stacked={tab === 'history' ? '' : undefined}>
|
|
1841
2263
|
{tab === 'history' ? (
|
|
1842
2264
|
<>
|
|
1843
2265
|
<CommitList
|
|
1844
2266
|
paneRef={commitsRef}
|
|
1845
|
-
style={
|
|
2267
|
+
style={paneTall(panes.commitsTall ?? null)}
|
|
1846
2268
|
t={t}
|
|
1847
2269
|
loading={historyLoading}
|
|
1848
2270
|
commits={commits}
|
|
@@ -1859,9 +2281,35 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1859
2281
|
fetchAuthors={fetchAuthors}
|
|
1860
2282
|
fetchRepoTree={fetchRepoTree}
|
|
1861
2283
|
/>
|
|
1862
|
-
<PaneDivider label={t('resizeCommits')} onDrag={
|
|
2284
|
+
<PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
|
|
1863
2285
|
</>
|
|
1864
2286
|
) : null}
|
|
2287
|
+
<div className={css.bodyRow}>
|
|
2288
|
+
{tab === 'files' ? (
|
|
2289
|
+
<FileBrowser
|
|
2290
|
+
t={t}
|
|
2291
|
+
palette={theme}
|
|
2292
|
+
statsPath={statsPath}
|
|
2293
|
+
extraPaths={browsablePaths}
|
|
2294
|
+
gen={gen}
|
|
2295
|
+
treeStyle={paneStyle(panes.tree)}
|
|
2296
|
+
treeRef={treeRef}
|
|
2297
|
+
divider={<PaneDivider label={t('resizeTree')} onDrag={paneDrag('tree', treeRef)} />}
|
|
2298
|
+
place={placeAt(filesPlaces, filesKey)}
|
|
2299
|
+
onPlace={rememberPlaceHere}
|
|
2300
|
+
cached={filesTrees.get(filesKey) ?? EMPTY_TREE}
|
|
2301
|
+
onTree={rememberTreeHere}
|
|
2302
|
+
fetchRepoTree={fetchRepoTree}
|
|
2303
|
+
fetchFileSides={fetchFileSides}
|
|
2304
|
+
writeChecked={writeChecked}
|
|
2305
|
+
fetchBlame={fetchBlame}
|
|
2306
|
+
fetchFileImage={fetchFileImage}
|
|
2307
|
+
onSaved={onRefresh}
|
|
2308
|
+
onDirtyChange={onSideDirty}
|
|
2309
|
+
onShowHistory={query => { onHistoryQuery(query); leaveTab('history') }}
|
|
2310
|
+
/>
|
|
2311
|
+
) : (
|
|
2312
|
+
<>
|
|
1865
2313
|
<div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
|
|
1866
2314
|
<FileTree
|
|
1867
2315
|
t={t}
|
|
@@ -1917,24 +2365,65 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1917
2365
|
{(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
|
|
1918
2366
|
: activeFile !== null && activeFile.binary ? (
|
|
1919
2367
|
<div className={css.empty}>{t('binaryFile')}</div>
|
|
2368
|
+
) : tab === 'changes' && active !== null ? (
|
|
2369
|
+
<SideBySideView
|
|
2370
|
+
t={t}
|
|
2371
|
+
path={active}
|
|
2372
|
+
palette={theme}
|
|
2373
|
+
statsPath={statsPath}
|
|
2374
|
+
fetchSides={fetchFileSides}
|
|
2375
|
+
writeChecked={writeChecked}
|
|
2376
|
+
scopeKey={viewKey}
|
|
2377
|
+
gen={gen}
|
|
2378
|
+
fallbackSegment={segment}
|
|
2379
|
+
fallbackLoading={loading && segment.length === 0}
|
|
2380
|
+
onBlockAction={askBlockAction}
|
|
2381
|
+
onSaved={onRefresh}
|
|
2382
|
+
onDirtyChange={onSideDirty}
|
|
2383
|
+
/>
|
|
1920
2384
|
) : loading && segment.length === 0 ? (
|
|
1921
2385
|
<div className={css.empty}>{t('loadingDiff')}</div>
|
|
1922
2386
|
) : segment.length > 0 ? (
|
|
1923
|
-
<DiffView segment={segment} path={active ?? ''} palette={theme} />
|
|
2387
|
+
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
|
|
1924
2388
|
) : (
|
|
1925
2389
|
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
1926
2390
|
)}
|
|
1927
2391
|
</div>
|
|
2392
|
+
</>
|
|
2393
|
+
)}
|
|
2394
|
+
</div>
|
|
1928
2395
|
</div>
|
|
1929
2396
|
{discardPending?.plan != null ? (
|
|
1930
2397
|
<DiscardConfirm
|
|
1931
2398
|
t={t}
|
|
1932
|
-
|
|
1933
|
-
plan={discardPending.plan}
|
|
2399
|
+
body={discardBodyText(t, discardPending.file, discardPending.plan)}
|
|
1934
2400
|
onCancel={() => setDiscardPending(null)}
|
|
1935
2401
|
onConfirm={confirmDiscard}
|
|
1936
2402
|
/>
|
|
1937
2403
|
) : null}
|
|
2404
|
+
{blockDiscard !== null ? (
|
|
2405
|
+
<DiscardConfirm
|
|
2406
|
+
t={t}
|
|
2407
|
+
body={blockDiscardBodyText(
|
|
2408
|
+
t,
|
|
2409
|
+
blockDiscard,
|
|
2410
|
+
body.files.find(file => file.path === blockDiscard.path),
|
|
2411
|
+
)}
|
|
2412
|
+
onCancel={() => setBlockDiscard(null)}
|
|
2413
|
+
onConfirm={confirmBlockDiscard}
|
|
2414
|
+
/>
|
|
2415
|
+
) : null}
|
|
2416
|
+
{/* The drawer-level unsaved-edits guard: the file being left is the
|
|
2417
|
+
one the reader was editing, and the deferred gesture — another
|
|
2418
|
+
file, another tab, closing — runs only on the dialog's answer. */}
|
|
2419
|
+
{pendingLeave !== null ? (
|
|
2420
|
+
<LeaveEditsConfirm
|
|
2421
|
+
t={t}
|
|
2422
|
+
path={active ?? ''}
|
|
2423
|
+
onCancel={settleLeaveAsk}
|
|
2424
|
+
onConfirm={confirmDrawerLeave}
|
|
2425
|
+
/>
|
|
2426
|
+
) : null}
|
|
1938
2427
|
</div>
|
|
1939
2428
|
</div>
|
|
1940
2429
|
)
|
|
@@ -1943,28 +2432,28 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1943
2432
|
/**
|
|
1944
2433
|
* The one dialog in this drawer, because this is the one act it cannot undo.
|
|
1945
2434
|
*
|
|
1946
|
-
* It never asks a generic "are you sure": the
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
2435
|
+
* It never asks a generic "are you sure": the caller hands it a body that names
|
|
2436
|
+
* the file and states which consequence is about to happen — the whole-file
|
|
2437
|
+
* roll-back's wording derived from the host's own reading of that file, the
|
|
2438
|
+
* block roll-back's from the pane's rows. Cancel holds the initial focus and
|
|
2439
|
+
* Escape closes, because the default answer to an irreversible question is no.
|
|
1950
2440
|
*
|
|
1951
2441
|
* There is deliberately no "don't ask again". This is the only path in the
|
|
1952
2442
|
* drawer with nothing behind it, and a checkbox whose whole function is to
|
|
1953
2443
|
* switch off the last guard is a feature that eventually gets clicked.
|
|
1954
2444
|
*/
|
|
1955
|
-
function DiscardConfirm({ t,
|
|
2445
|
+
function DiscardConfirm({ t, body, onCancel, onConfirm }: {
|
|
1956
2446
|
t: Translate
|
|
1957
|
-
|
|
1958
|
-
plan: DiscardPreview
|
|
2447
|
+
body: string
|
|
1959
2448
|
onCancel: () => void
|
|
1960
2449
|
onConfirm: () => void
|
|
1961
2450
|
}): ReactNode {
|
|
1962
2451
|
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
1963
2452
|
useEffect(() => { cancelRef.current?.focus() }, [])
|
|
1964
2453
|
useEffect(() => {
|
|
1965
|
-
// Capture phase:
|
|
1966
|
-
//
|
|
1967
|
-
// the
|
|
2454
|
+
// Capture phase: while this question is open, Escape belongs to it alone
|
|
2455
|
+
// — consumed here, before it can reach the page's other Escape handlers
|
|
2456
|
+
// (an open picker's dismiss, the commit box's undo).
|
|
1968
2457
|
const onKey = (event: KeyboardEvent): void => {
|
|
1969
2458
|
if (event.key !== 'Escape') return
|
|
1970
2459
|
event.stopPropagation()
|
|
@@ -1974,12 +2463,6 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
|
|
|
1974
2463
|
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
1975
2464
|
}, [onCancel])
|
|
1976
2465
|
|
|
1977
|
-
const body = plan.effect === 'delete'
|
|
1978
|
-
? t('discardBodyDelete', { path: file.path })
|
|
1979
|
-
: plan.effect === 'unrename'
|
|
1980
|
-
? t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
|
|
1981
|
-
: t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
|
|
1982
|
-
|
|
1983
2466
|
return (
|
|
1984
2467
|
<div className={css.confirmScrim} onClick={onCancel}>
|
|
1985
2468
|
<div
|
|
@@ -2000,6 +2483,35 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
|
|
|
2000
2483
|
)
|
|
2001
2484
|
}
|
|
2002
2485
|
|
|
2486
|
+
/**
|
|
2487
|
+
* The whole-file roll-back's consequence, in the host's own fresh reading of
|
|
2488
|
+
* the file — the difference between "goes back to its committed content" and
|
|
2489
|
+
* "leaves the disk and cannot come back" is the entire question the dialog
|
|
2490
|
+
* asks, and it is exactly what a stale row gets wrong.
|
|
2491
|
+
*/
|
|
2492
|
+
function discardBodyText(t: Translate, file: GitFile, plan: DiscardPreview): string {
|
|
2493
|
+
if (plan.effect === 'delete') return t('discardBodyDelete', { path: file.path })
|
|
2494
|
+
if (plan.effect === 'unrename') return t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
|
|
2495
|
+
return t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* The BLOCK roll-back's consequence, in the pane's own rows.
|
|
2500
|
+
*
|
|
2501
|
+
* One case outranks the tally wording: an untracked file's whole content is
|
|
2502
|
+
* the one block, and rolling THAT block back reverse-applies the new-file
|
|
2503
|
+
* patch — which deletes the file from the working tree, not rewrites it. The
|
|
2504
|
+
* file row's status is the gate (a tracked file whose every line changed has
|
|
2505
|
+
* the same block shape and only rewrites), which is why the ask's shape alone
|
|
2506
|
+
* is not enough.
|
|
2507
|
+
*/
|
|
2508
|
+
function blockDiscardBodyText(t: Translate, ask: BlockAsk, file: GitFile | undefined): string {
|
|
2509
|
+
if (file !== undefined && file.status === 'untracked' && ask.wholeFile) {
|
|
2510
|
+
return t('blockDiscardBodyDelete', { path: ask.path })
|
|
2511
|
+
}
|
|
2512
|
+
return t('blockDiscardBody', { path: ask.path, added: ask.added, deleted: ask.deleted })
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2003
2515
|
/**
|
|
2004
2516
|
* A slash-separated name that gives up its HEAD, never its tail.
|
|
2005
2517
|
*
|
|
@@ -3006,9 +3518,17 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
|
|
|
3006
3518
|
*/
|
|
3007
3519
|
/* ---------- commit graph ---------- */
|
|
3008
3520
|
|
|
3009
|
-
/**
|
|
3010
|
-
*
|
|
3011
|
-
|
|
3521
|
+
/**
|
|
3522
|
+
* Row height the graph and the list agree on. The lines only join up if every
|
|
3523
|
+
* row is exactly as tall as the segment drawn for it, so this number and
|
|
3524
|
+
* `.commitLine`'s `height` are one fact with two homes —
|
|
3525
|
+
* `tests/commit-row-height.test.ts` holds them together.
|
|
3526
|
+
*
|
|
3527
|
+
* 28 since the History tab stacked: the list spans the drawer's full width, a
|
|
3528
|
+
* commit fits on one line, and 48px of row would spend the vertical space the
|
|
3529
|
+
* stacked layout has least of on empty padding.
|
|
3530
|
+
*/
|
|
3531
|
+
const GRAPH_ROW_H = 28
|
|
3012
3532
|
/** Horizontal distance between lanes. */
|
|
3013
3533
|
const GRAPH_LANE_W = 14
|
|
3014
3534
|
/** Ref chips shown inline before the subject; the rest collapse into a "+N". */
|
|
@@ -3161,11 +3681,12 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3161
3681
|
onMouseEnter={show}
|
|
3162
3682
|
onMouseLeave={hide}
|
|
3163
3683
|
>
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3684
|
+
{/* One line, in git log --oneline's order: a fixed-width hash, then
|
|
3685
|
+
the subject, then who and when pushed to the right. The hash
|
|
3686
|
+
being fixed width is what aligns every subject into a column the
|
|
3687
|
+
eye can run down — leading with the author's name instead would
|
|
3688
|
+
start each subject at a different place. */}
|
|
3689
|
+
<code className={css.commitHash}>{commit.hash}</code>
|
|
3169
3690
|
<span className={css.commitSubjectRow}>
|
|
3170
3691
|
{/* Capped at two. A release commit can carry six refs, and the
|
|
3171
3692
|
subject is what the row is actually for — the rest are counted
|
|
@@ -3181,6 +3702,10 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3181
3702
|
<span className={css.commitSubject}>{commit.subject}</span>
|
|
3182
3703
|
{body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
|
|
3183
3704
|
</span>
|
|
3705
|
+
<span className={css.commitMeta}>
|
|
3706
|
+
{authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
|
|
3707
|
+
<span className={css.commitWhen}>{commit.when}</span>
|
|
3708
|
+
</span>
|
|
3184
3709
|
</button>
|
|
3185
3710
|
</div>
|
|
3186
3711
|
{open && box !== null && host !== null ? createPortal(
|
|
@@ -3274,58 +3799,6 @@ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }:
|
|
|
3274
3799
|
)
|
|
3275
3800
|
}
|
|
3276
3801
|
|
|
3277
|
-
/**
|
|
3278
|
-
* The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
|
|
3279
|
-
* strokes, no fill, rounded joins — outlines, where the old UI shipped filled
|
|
3280
|
-
* silhouettes. Hand-drawn here rather than imported, because the bundle purity
|
|
3281
|
-
* gate forbids an icon package and the drawer needs exactly these two; they
|
|
3282
|
-
* are shapes in that language, not JetBrains' own assets.
|
|
3283
|
-
*
|
|
3284
|
-
* `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
|
|
3285
|
-
* stroke lands on a whole pixel instead of straddling two.
|
|
3286
|
-
*
|
|
3287
|
-
* Every place the drawer names a file or a directory uses these: the path
|
|
3288
|
-
* picker in the history filter, and the file tree behind all three tabs. The
|
|
3289
|
-
* CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
|
|
3290
|
-
* selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
|
|
3291
|
-
*/
|
|
3292
|
-
function PathDirGlyph(): ReactNode {
|
|
3293
|
-
return (
|
|
3294
|
-
<svg
|
|
3295
|
-
className={css.pathDirGlyph}
|
|
3296
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
3297
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
3298
|
-
strokeLinejoin="round" strokeLinecap="round"
|
|
3299
|
-
aria-hidden="true"
|
|
3300
|
-
>
|
|
3301
|
-
{/* Body, with the tab stepping up over the left third. The step is a
|
|
3302
|
-
full 2px: at 1.3px it read as a rounded rectangle with a nick in it
|
|
3303
|
-
rather than a folder. Every straight edge sits on a .5 coordinate so
|
|
3304
|
-
a 1px stroke lands on one pixel instead of straddling two. */}
|
|
3305
|
-
<path d="M2.5 12.75V4.25A.75.75 0 0 1 3.25 3.5H6l1.6 2h5.15A.75.75 0 0 1 13.5 6.25v6.5a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75Z" />
|
|
3306
|
-
</svg>
|
|
3307
|
-
)
|
|
3308
|
-
}
|
|
3309
|
-
|
|
3310
|
-
function PathFileGlyph(): ReactNode {
|
|
3311
|
-
return (
|
|
3312
|
-
<svg
|
|
3313
|
-
className={css.pathFileGlyph}
|
|
3314
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
3315
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
3316
|
-
strokeLinejoin="round" strokeLinecap="round"
|
|
3317
|
-
aria-hidden="true"
|
|
3318
|
-
>
|
|
3319
|
-
{/* Sheet, cut back at the top-right for the fold. Narrower and one step
|
|
3320
|
-
taller than the folder, sharing its optical band, so the two never
|
|
3321
|
-
look like different-sized icons in one column. */}
|
|
3322
|
-
<path d="M3.5 12.75V3.25A.75.75 0 0 1 4.25 2.5H9l3.5 3.5v6.75a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z" />
|
|
3323
|
-
{/* The fold itself — the corner turned back on the sheet. */}
|
|
3324
|
-
<path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
|
|
3325
|
-
</svg>
|
|
3326
|
-
)
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
3802
|
/** Files shown per expanded directory. The search box is the way to a file in
|
|
3330
3803
|
* a crowded directory; the tree shows enough to browse without flooding the
|
|
3331
3804
|
* list, and says so when it cut the tail. */
|
|
@@ -3403,7 +3876,7 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
|
|
|
3403
3876
|
<label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
|
|
3404
3877
|
<span className={css.funnelChevron} aria-hidden="true" />
|
|
3405
3878
|
<TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
|
|
3406
|
-
<PathFileGlyph />
|
|
3879
|
+
<PathFileGlyph path={`${dir.path}/${file}`} />
|
|
3407
3880
|
<span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
|
|
3408
3881
|
</label>
|
|
3409
3882
|
))}
|
|
@@ -3557,6 +4030,12 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3557
4030
|
return () => { ctrl.abort() }
|
|
3558
4031
|
}, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
|
|
3559
4032
|
|
|
4033
|
+
// Where the popover mounts: the drawer's overlay layer when there is one,
|
|
4034
|
+
// the body otherwise. Same resolution the commit-row popover does, and the
|
|
4035
|
+
// render below skips the portal when neither exists rather than handing
|
|
4036
|
+
// createPortal a null container.
|
|
4037
|
+
const funnelHost = funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
|
|
4038
|
+
|
|
3560
4039
|
/** Every funnel interaction writes the filter through the box's grammar, so
|
|
3561
4040
|
* the box, the chips and the fetch can never disagree about the query. */
|
|
3562
4041
|
const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
|
|
@@ -3652,7 +4131,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3652
4131
|
spellCheck={false}
|
|
3653
4132
|
/>
|
|
3654
4133
|
</div>
|
|
3655
|
-
{funnelOpen && funnelBox !== null ? createPortal(
|
|
4134
|
+
{funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
|
|
3656
4135
|
<div
|
|
3657
4136
|
ref={funnelPanelRef}
|
|
3658
4137
|
className={css.funnelPop}
|
|
@@ -3814,7 +4293,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3814
4293
|
{hits.map(hit => (
|
|
3815
4294
|
<label key={hit.path} className={css.funnelRow}>
|
|
3816
4295
|
<TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
|
|
3817
|
-
{hit.isFile ? <PathFileGlyph /> : <PathDirGlyph />}
|
|
4296
|
+
{hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
|
|
3818
4297
|
<span className={css.funnelName} title={hit.path}>{hit.path}</span>
|
|
3819
4298
|
</label>
|
|
3820
4299
|
))}
|
|
@@ -3857,7 +4336,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3857
4336
|
>{t('filterClearAll')}</button>
|
|
3858
4337
|
</div>
|
|
3859
4338
|
</div>,
|
|
3860
|
-
|
|
4339
|
+
funnelHost,
|
|
3861
4340
|
) : null}
|
|
3862
4341
|
{chips.length > 0 ? (
|
|
3863
4342
|
<div className={css.filterChips}>
|
|
@@ -4350,7 +4829,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
|
|
|
4350
4829
|
left-to-right as "what this is, then what happened to it",
|
|
4351
4830
|
and the badge still lands in an aligned column — `.filePath`
|
|
4352
4831
|
is the only flexible child. */}
|
|
4353
|
-
<PathFileGlyph />
|
|
4832
|
+
<PathFileGlyph path={file.path} />
|
|
4354
4833
|
<span className={css.filePath}>{basePart(file.path)}</span>
|
|
4355
4834
|
{file.binary ? <span className={css.fileBinary}>BIN</span> : (
|
|
4356
4835
|
<span className={css.fileCounts}>
|
|
@@ -4380,8 +4859,27 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
|
|
|
4380
4859
|
|
|
4381
4860
|
/* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
|
|
4382
4861
|
|
|
4383
|
-
/**
|
|
4384
|
-
|
|
4862
|
+
/**
|
|
4863
|
+
* Render one file's unified-diff segment with word-level highlights and Shiki.
|
|
4864
|
+
*
|
|
4865
|
+
* This is what History and Compare show, and — unlike the side-by-side pane —
|
|
4866
|
+
* it has no row model carrying block ids, because nothing here acts on a block:
|
|
4867
|
+
* a commit's contents were decided long ago, so there is no staging and no
|
|
4868
|
+
* roll-back. The walk still needs them, so the runs are read off the row kinds
|
|
4869
|
+
* by `unifiedBlocks` and marked on the rows that scroll.
|
|
4870
|
+
*
|
|
4871
|
+
* The scroller is this component's own rather than the pane's. A bar that
|
|
4872
|
+
* scrolls away is not a control, and the pane scrolls in BOTH directions —
|
|
4873
|
+
* `sticky` fixes the vertical half and nothing fixes the horizontal one, since
|
|
4874
|
+
* a block child of a scroller is only ever as wide as the scrollport. A header
|
|
4875
|
+
* outside the scrolled box has neither problem.
|
|
4876
|
+
*/
|
|
4877
|
+
function DiffView({ segment, path, palette, t }: {
|
|
4878
|
+
segment: string
|
|
4879
|
+
path: string
|
|
4880
|
+
palette: string
|
|
4881
|
+
t: Translate
|
|
4882
|
+
}): ReactNode {
|
|
4385
4883
|
const lang = shikiLangOf(path)
|
|
4386
4884
|
const shikiTheme = shikiThemeOf(palette)
|
|
4387
4885
|
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
@@ -4391,10 +4889,57 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
|
|
|
4391
4889
|
() => highlightForRows(rowsWithWords, lang, shikiTheme),
|
|
4392
4890
|
[rowsWithWords, lang, shikiTheme, grammarGen],
|
|
4393
4891
|
)
|
|
4892
|
+
const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
|
|
4893
|
+
const changes = useMemo(() => countBlocks(blocks), [blocks])
|
|
4894
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
4895
|
+
const { goToChange } = useChangeNav(scrollRef)
|
|
4896
|
+
// Read by the key listener below, which is attached once. `goToChange` only
|
|
4897
|
+
// ever touches refs, but pinning it here says so rather than relying on it.
|
|
4898
|
+
const walk = useRef(goToChange)
|
|
4899
|
+
walk.current = goToChange
|
|
4900
|
+
|
|
4901
|
+
// F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
|
|
4902
|
+
// that scrolls — so the key and the buttons cannot disagree about which pane
|
|
4903
|
+
// they move. `tabIndex` is what makes it able to receive the key at all:
|
|
4904
|
+
// diff text is not focusable, and a click on it would otherwise leave focus
|
|
4905
|
+
// on the document body.
|
|
4906
|
+
useEffect(() => {
|
|
4907
|
+
const scroller = scrollRef.current
|
|
4908
|
+
if (scroller === null) return
|
|
4909
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
4910
|
+
if (event.key !== 'F7') return
|
|
4911
|
+
event.preventDefault()
|
|
4912
|
+
walk.current(event.shiftKey ? -1 : 1)
|
|
4913
|
+
}
|
|
4914
|
+
scroller.addEventListener('keydown', onKey)
|
|
4915
|
+
return () => { scroller.removeEventListener('keydown', onKey) }
|
|
4916
|
+
}, [])
|
|
4917
|
+
|
|
4394
4918
|
return (
|
|
4919
|
+
<div className={css.diffWrap}>
|
|
4920
|
+
{changes > 0 ? (
|
|
4921
|
+
<div className={css.diffNav}>
|
|
4922
|
+
<button
|
|
4923
|
+
type="button"
|
|
4924
|
+
className={css.blockBtn}
|
|
4925
|
+
title={t('prevChangeHint')}
|
|
4926
|
+
aria-label={t('prevChange')}
|
|
4927
|
+
onClick={() => { goToChange(-1) }}
|
|
4928
|
+
><NavGlyph of="prev" /></button>
|
|
4929
|
+
<button
|
|
4930
|
+
type="button"
|
|
4931
|
+
className={css.blockBtn}
|
|
4932
|
+
title={t('nextChangeHint')}
|
|
4933
|
+
aria-label={t('nextChange')}
|
|
4934
|
+
onClick={() => { goToChange(1) }}
|
|
4935
|
+
><NavGlyph of="next" /></button>
|
|
4936
|
+
<span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
|
|
4937
|
+
</div>
|
|
4938
|
+
) : null}
|
|
4939
|
+
<div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
|
|
4395
4940
|
<pre className={css.diffPre}>
|
|
4396
4941
|
{rowsWithWords.map((row, i) => (
|
|
4397
|
-
<div key={i} className={`${css.line} ${rowClass(row.kind)}`}>
|
|
4942
|
+
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
|
|
4398
4943
|
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
4399
4944
|
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
4400
4945
|
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
@@ -4404,6 +4949,8 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
|
|
|
4404
4949
|
</div>
|
|
4405
4950
|
))}
|
|
4406
4951
|
</pre>
|
|
4952
|
+
</div>
|
|
4953
|
+
</div>
|
|
4407
4954
|
)
|
|
4408
4955
|
}
|
|
4409
4956
|
|
|
@@ -4429,6 +4976,752 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
|
|
|
4429
4976
|
))
|
|
4430
4977
|
}
|
|
4431
4978
|
|
|
4979
|
+
/* ---------- side-by-side diff rendering (working tree only) ---------- */
|
|
4980
|
+
|
|
4981
|
+
/**
|
|
4982
|
+
* The working tree's per-file diff as IDEA shows it: one tab per layer of the
|
|
4983
|
+
* index, two columns with the whole file, aligned row by row.
|
|
4984
|
+
*
|
|
4985
|
+
* The rows come from `side-rows.ts` over the layer's full-context diff, so the
|
|
4986
|
+
* alignment is read off the diff rather than computed. A change block — a
|
|
4987
|
+
* maximal run of changed rows — carries its own actions: hovering any of its
|
|
4988
|
+
* cells outlines the whole block and floats its buttons (stage + roll back on
|
|
4989
|
+
* the unstaged tab, unstage on the staged one). The click carries the block's
|
|
4990
|
+
* hunk-line indices and the rendered diff's sha, so the host can prove the
|
|
4991
|
+
* file has not changed since the pane drew it.
|
|
4992
|
+
*
|
|
4993
|
+
* The unstaged tab's right column is also EDITABLE (the staged one is not, by
|
|
4994
|
+
* design: editing the index would mean writing a blob with no file behind it).
|
|
4995
|
+
* Editing arms explicitly — never per keystroke — and the buffer's whole life
|
|
4996
|
+
* against the file and the poll is `side-edit.ts`'s to decide: a refresh over
|
|
4997
|
+
* a dirty buffer keeps the buffer, a file that moved underneath raises the
|
|
4998
|
+
* reload-or-overwrite banner, and the one save path carries the sha the buffer
|
|
4999
|
+
* is based on so the host can refuse a stale write. While editing, the layout
|
|
5000
|
+
* trades the diff's hole-aligned grid for a dense editor column (same
|
|
5001
|
+
* metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
|
|
5002
|
+
* dense buffer whenever deletions outrun additions, and re-diffing per
|
|
5003
|
+
* keystroke is exactly the editor-library work the first cut declines.
|
|
5004
|
+
*
|
|
5005
|
+
* `tooLarge` and `binary` fall back to the unified view the pane already had
|
|
5006
|
+
* (history and compare keep it unconditionally), with a notice — a silently
|
|
5007
|
+
* different view reads as a broken one, not a guarded one.
|
|
5008
|
+
*/
|
|
5009
|
+
function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
5010
|
+
t: Translate
|
|
5011
|
+
path: string
|
|
5012
|
+
palette: string
|
|
5013
|
+
statsPath: string | undefined
|
|
5014
|
+
fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
5015
|
+
/** Save the editor buffer; the host refuses a stale sha and nothing is written. */
|
|
5016
|
+
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
5017
|
+
/** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
|
|
5018
|
+
scopeKey: string
|
|
5019
|
+
/** Refresh generation: a new one means the tree was re-read, so refetch. */
|
|
5020
|
+
gen: number
|
|
5021
|
+
/** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
|
|
5022
|
+
* the drawer noticed the file move, so the pane refetches even between
|
|
5023
|
+
* refresh generations — this is how the poll reaches a dirty buffer. */
|
|
5024
|
+
fallbackSegment: string
|
|
5025
|
+
fallbackLoading: boolean
|
|
5026
|
+
/** Run one block action; a discard routes to the drawer's confirmation. */
|
|
5027
|
+
onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
|
|
5028
|
+
/** After a successful save: refresh the tree and the pane together. */
|
|
5029
|
+
onSaved: () => void
|
|
5030
|
+
/** Reports the buffer's dirty flag outward: the drawer guards every
|
|
5031
|
+
* gesture that would drop the buffer (file selection, close, main tab)
|
|
5032
|
+
* on it, so it must live where those gestures are handled. */
|
|
5033
|
+
onDirtyChange: (dirty: boolean) => void
|
|
5034
|
+
}): ReactNode {
|
|
5035
|
+
const [layer, setLayer] = useState<SideLayer>('unstaged')
|
|
5036
|
+
// How much of the pane the left column gets. Lives here rather than in the
|
|
5037
|
+
// drawer so it is one setting for the pane, and survives a file switch —
|
|
5038
|
+
// the reader sized the columns for how they read, not for one file.
|
|
5039
|
+
const [split, setSplit] = useState(0.5)
|
|
5040
|
+
const colsRef = useRef<HTMLDivElement>(null)
|
|
5041
|
+
/** The pane's one vertical scroller — what "next change" moves. */
|
|
5042
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
5043
|
+
const { goToChange } = useChangeNav(scrollRef)
|
|
5044
|
+
|
|
5045
|
+
const [sides, setSides] = useState<FileSides | null>(null)
|
|
5046
|
+
// Set when the RPC itself failed — most plausibly a host half older than
|
|
5047
|
+
// this client (the two halves reload on different cycles). The unified view
|
|
5048
|
+
// still renders, so an old host costs the new pane, not the diff.
|
|
5049
|
+
const [failed, setFailed] = useState(false)
|
|
5050
|
+
// The block under the pointer, or null over context rows and gutters. Hover
|
|
5051
|
+
// names the BLOCK, not the cell: the outline and the buttons belong to a
|
|
5052
|
+
// whole run of rows, and a per-cell affordance would scatter them.
|
|
5053
|
+
const [hotBlock, setHotBlock] = useState<number | null>(null)
|
|
5054
|
+
// The block whose stage/unstage call is in flight, disabling its buttons.
|
|
5055
|
+
const [pendingBlock, setPendingBlock] = useState<number | null>(null)
|
|
5056
|
+
// The editable right column's state and its one save path. `saving` disables
|
|
5057
|
+
// the controls for the call's duration; `saveFailed` carries a non-stale
|
|
5058
|
+
// failure's sentence (the stale case is `edit.conflict`'s banner instead).
|
|
5059
|
+
const [edit, setEdit] = useState<EditState>(DISARMED)
|
|
5060
|
+
const [saving, setSaving] = useState(false)
|
|
5061
|
+
const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
|
|
5062
|
+
// The layer a dirty-buffer tab switch is waiting on the reader to confirm.
|
|
5063
|
+
const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
|
|
5064
|
+
// Internal fetch generation: a stale save or the banner's reload refetch
|
|
5065
|
+
// without waiting for the drawer's next refresh.
|
|
5066
|
+
const [refetch, setRefetch] = useState(0)
|
|
5067
|
+
// Which edit session the fetched payload belongs to, and what a landing
|
|
5068
|
+
// payload may do with the buffer. The ref is read inside the fetch callback
|
|
5069
|
+
// (which closes over a render that may be several states old by the time the
|
|
5070
|
+
// answer arrives), and the adopt mode is consumed once by the next run.
|
|
5071
|
+
const idRef = useRef('')
|
|
5072
|
+
const adoptRef = useRef<'auto' | 'reload'>('auto')
|
|
5073
|
+
const editRef = useRef(edit)
|
|
5074
|
+
editRef.current = edit
|
|
5075
|
+
|
|
5076
|
+
// Switching tabs refetches: the two layers are different diffs of the same
|
|
5077
|
+
// file, and neither is a transform of the other client-side. A change in the
|
|
5078
|
+
// drawer's polled segment for this file refetches too — the poll's way of
|
|
5079
|
+
// saying the file moved — which is what lets a change under a DIRTY buffer
|
|
5080
|
+
// raise the banner within one poll interval instead of at the next refresh.
|
|
5081
|
+
//
|
|
5082
|
+
// Only a NEW file/layer/scope may blank the pane and disarm the editor; a
|
|
5083
|
+
// refetch of the same identity keeps the current payload on screen until the
|
|
5084
|
+
// answer lands, because blanking it would unmount the editor mid-keystroke.
|
|
5085
|
+
useEffect(() => {
|
|
5086
|
+
const id = `${scopeKey}\x1f${path}\x1f${layer}`
|
|
5087
|
+
const identityChanged = idRef.current !== id
|
|
5088
|
+
if (identityChanged) idRef.current = id
|
|
5089
|
+
const adopt = identityChanged ? 'reset' : adoptRef.current
|
|
5090
|
+
adoptRef.current = 'auto'
|
|
5091
|
+
const ctrl = new AbortController()
|
|
5092
|
+
let alive = true
|
|
5093
|
+
if (identityChanged) {
|
|
5094
|
+
setSides(null)
|
|
5095
|
+
setSaveFailed(null)
|
|
5096
|
+
setPendingLayer(null)
|
|
5097
|
+
}
|
|
5098
|
+
if (identityChanged || !editRef.current.armed) setFailed(false)
|
|
5099
|
+
fetchSides(statsPath, path, layer, ctrl.signal)
|
|
5100
|
+
.then(value => {
|
|
5101
|
+
if (!alive) return
|
|
5102
|
+
// While armed, a failed background refetch keeps the pane as it is:
|
|
5103
|
+
// dropping the editor over a transient RPC failure would cost the
|
|
5104
|
+
// buffer's DOM (focus, IME composition) for no reader benefit.
|
|
5105
|
+
if (value === null) {
|
|
5106
|
+
if (!editRef.current.armed) setFailed(true)
|
|
5107
|
+
return
|
|
5108
|
+
}
|
|
5109
|
+
setSides(value)
|
|
5110
|
+
setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
|
|
5111
|
+
: adopt === 'reload' ? reloadSides(prev, value)
|
|
5112
|
+
: applySides(prev, value))
|
|
5113
|
+
})
|
|
5114
|
+
.catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
|
|
5115
|
+
return () => { alive = false; ctrl.abort() }
|
|
5116
|
+
}, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
|
|
5117
|
+
|
|
5118
|
+
const lang = shikiLangOf(path)
|
|
5119
|
+
const shikiTheme = shikiThemeOf(palette)
|
|
5120
|
+
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
5121
|
+
const rows = useMemo(() => {
|
|
5122
|
+
if (sides === null || sides.diff.length === 0) return []
|
|
5123
|
+
const file = parsePatch(sides.diff)
|
|
5124
|
+
// A diff with no hunk (mode-only change, or text patch-model cannot parse)
|
|
5125
|
+
// has no rows to align; the no-change treatment below is the honest view.
|
|
5126
|
+
return file === null ? [] : alignRows(file)
|
|
5127
|
+
}, [sides])
|
|
5128
|
+
// Highlight each column as one file — a row is not a program, and lexing
|
|
5129
|
+
// fragments is what made the unified view paint keywords as plain text.
|
|
5130
|
+
//
|
|
5131
|
+
// These are UNDEFINED until a lazy grammar loads, and stay undefined for a
|
|
5132
|
+
// file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
|
|
5133
|
+
// every read below is optional-chained. `renderSideCode` already takes
|
|
5134
|
+
// `undefined` and renders the plain text for it; what crashes is indexing
|
|
5135
|
+
// the array itself, and `strict` is off in tsconfig, so the compiler will
|
|
5136
|
+
// not say so.
|
|
5137
|
+
const leftSyntax = useMemo(
|
|
5138
|
+
() => highlightFile(rows.map(row => row.left === null ? '' : row.left.text), lang, shikiTheme),
|
|
5139
|
+
[rows, lang, shikiTheme, grammarGen],
|
|
5140
|
+
)
|
|
5141
|
+
const rightSyntax = useMemo(
|
|
5142
|
+
() => highlightFile(rows.map(row => row.right === null ? '' : row.right.text), lang, shikiTheme),
|
|
5143
|
+
[rows, lang, shikiTheme, grammarGen],
|
|
5144
|
+
)
|
|
5145
|
+
|
|
5146
|
+
/** The editor half of the pane, present only on the unstaged layer. */
|
|
5147
|
+
const editable = layer === 'unstaged' && edit.armed
|
|
5148
|
+
const dirty = isDirty(edit)
|
|
5149
|
+
// Whether this payload may enter the editor at all: text carrying \r would
|
|
5150
|
+
// be normalised to \n by the textarea the moment it landed, and the next
|
|
5151
|
+
// save would rewrite every line ending in the file. The gate lives in
|
|
5152
|
+
// `side-edit.ts` with the rest of the buffer's rules.
|
|
5153
|
+
const armable = sides !== null && editableSides(sides)
|
|
5154
|
+
// Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
|
|
5155
|
+
// different problems, and one message for both leaves the reader guessing
|
|
5156
|
+
// whether converting line endings would help.
|
|
5157
|
+
const refusal = sides === null ? null : armRefusal(sides)
|
|
5158
|
+
|
|
5159
|
+
|
|
5160
|
+
// The drawer guards every gesture that would drop the buffer — selecting
|
|
5161
|
+
// another file, closing, switching the main tab — so it needs the flag as
|
|
5162
|
+
// it changes, not at click time from a stale render. Reported on the FLAG
|
|
5163
|
+
// (not the buffer) so it fires on the transitions that matter; the cleanup
|
|
5164
|
+
// clears it when this pane unmounts, so no orphaned flag prompts later.
|
|
5165
|
+
useEffect(() => {
|
|
5166
|
+
onDirtyChange(dirty)
|
|
5167
|
+
return () => { onDirtyChange(false) }
|
|
5168
|
+
}, [dirty, onDirtyChange])
|
|
5169
|
+
// The buffer's lines and their highlight, for the editor's underlay: the
|
|
5170
|
+
// visible text under the transparent textarea, which is what keeps syntax
|
|
5171
|
+
// coloring and the caret on the same grid while typing.
|
|
5172
|
+
const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
|
|
5173
|
+
// What Tab inserts, learned from the file rather than configured. Keyed on
|
|
5174
|
+
// the BASE text, not the buffer: re-detecting mid-edit would let a couple of
|
|
5175
|
+
// freshly typed lines redefine the unit under the reader's hands.
|
|
5176
|
+
const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
|
|
5177
|
+
// The index side as one text, which is what the editor tints against while
|
|
5178
|
+
// the reader types. It is the diff's own left column joined back up — every
|
|
5179
|
+
// row of a full-context diff carries a left cell unless the line is an
|
|
5180
|
+
// addition, which by definition is not on that side.
|
|
5181
|
+
const indexText = useMemo(() => {
|
|
5182
|
+
const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
|
|
5183
|
+
return left.length === 0 ? '' : left.join('\n') + '\n'
|
|
5184
|
+
}, [rows])
|
|
5185
|
+
// The editor's buffer is a whole file, so it takes the whole-file pass —
|
|
5186
|
+
// and it lags the typing, for the same reason the browser's does: a Shiki
|
|
5187
|
+
// pass per keystroke is what makes a large file unusable to edit.
|
|
5188
|
+
const paintedLines = useIdleValue(bufferLines, HIGHLIGHT_IDLE_MS)
|
|
5189
|
+
const editSyntax = useMemo(
|
|
5190
|
+
() => edit.armed && paintedLines.length <= HIGHLIGHT_LINE_CAP
|
|
5191
|
+
? highlightWholeFile(paintedLines, lang, shikiTheme)
|
|
5192
|
+
: [],
|
|
5193
|
+
[edit.armed, paintedLines, lang, shikiTheme, grammarGen],
|
|
5194
|
+
)
|
|
5195
|
+
// The left column while editing renders dense — one row per INDEX line, no
|
|
5196
|
+
// holes — because the right column is now the dense buffer; a hole-aligned
|
|
5197
|
+
// left beside a dense right is the alignment the diff view owes, not the
|
|
5198
|
+
// editor. Each entry keeps its index into `rows` for its syntax tokens.
|
|
5199
|
+
const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
|
|
5200
|
+
|
|
5201
|
+
// Arming drops the caret straight into the buffer: the click that armed the
|
|
5202
|
+
// editor said "I want to type here", and a second click to focus is a tax.
|
|
5203
|
+
|
|
5204
|
+
/** Arm the editor from the payload on screen; the unstaged tab, and only
|
|
5205
|
+
* for a payload `editableSides` accepts — armEdit itself refuses the rest,
|
|
5206
|
+
* so even a stray call cannot put CRLF text into the buffer. */
|
|
5207
|
+
const arm = (): void => {
|
|
5208
|
+
if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
|
|
5209
|
+
setEdit(prev => armEdit(prev, sides))
|
|
5210
|
+
}
|
|
5211
|
+
|
|
5212
|
+
/**
|
|
5213
|
+
* The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
|
|
5214
|
+
* overwrite action — they differ only in WHICH sha the host is asked to
|
|
5215
|
+
* check: the buffer's basis for a save, the file as it stands NOW for an
|
|
5216
|
+
* explicit overwrite of a concurrent writer's version.
|
|
5217
|
+
*
|
|
5218
|
+
* On success the basis moves to the sha the host read back and the drawer
|
|
5219
|
+
* refreshes (tree and pane together). On `stale` the banner goes up and the
|
|
5220
|
+
* pane refetches WITHOUT touching the buffer, so the banner's reload and
|
|
5221
|
+
* overwrite actions read the file's true current state. Everything else is
|
|
5222
|
+
* a failed save with a sentence.
|
|
5223
|
+
*/
|
|
5224
|
+
const runSave = async (expectedSha: string): Promise<void> => {
|
|
5225
|
+
if (sides === null || !dirty || saving) return
|
|
5226
|
+
const savedText = edit.buffer
|
|
5227
|
+
// The edit session this save belongs to. A slow RPC can outlive a file or
|
|
5228
|
+
// layer switch, and applying THIS save's outcome to the NEXT file's edit
|
|
5229
|
+
// state would re-base that buffer onto text it never held — so every
|
|
5230
|
+
// pane-local effect below is gated on the session still being current.
|
|
5231
|
+
// The tree refresh on success is not: the file on disk did move.
|
|
5232
|
+
const session = idRef.current
|
|
5233
|
+
setSaving(true)
|
|
5234
|
+
try {
|
|
5235
|
+
const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
|
|
5236
|
+
const stillHere = idRef.current === session
|
|
5237
|
+
if (result === null) {
|
|
5238
|
+
if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
|
|
5239
|
+
} else if (result.ok) {
|
|
5240
|
+
if (stillHere) {
|
|
5241
|
+
setSaveFailed(null)
|
|
5242
|
+
setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
|
|
5243
|
+
}
|
|
5244
|
+
onSaved()
|
|
5245
|
+
} else if (result.failure === 'stale') {
|
|
5246
|
+
if (stillHere) {
|
|
5247
|
+
setEdit(prev => markConflict(prev))
|
|
5248
|
+
setRefetch(n => n + 1)
|
|
5249
|
+
}
|
|
5250
|
+
} else {
|
|
5251
|
+
if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
|
|
5252
|
+
}
|
|
5253
|
+
} finally {
|
|
5254
|
+
setSaving(false)
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
|
|
5258
|
+
/**
|
|
5259
|
+
* The banner's two answers to a file that moved underneath. Overwrite may
|
|
5260
|
+
* only run once the post-refusal refetch has landed (the fresh targetSha is
|
|
5261
|
+
* what the host checks the overwrite against); until then the button waits,
|
|
5262
|
+
* because re-sending the refused sha would just refuse again.
|
|
5263
|
+
*/
|
|
5264
|
+
const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
|
|
5265
|
+
const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
|
|
5266
|
+
/** Reload: the reader chose the file over the buffer; drop the edits. */
|
|
5267
|
+
const reload = (): void => {
|
|
5268
|
+
setSaveFailed(null)
|
|
5269
|
+
adoptRef.current = 'reload'
|
|
5270
|
+
setRefetch(n => n + 1)
|
|
5271
|
+
}
|
|
5272
|
+
/** Revert the buffer to its basis, in place; the conflict flag stands. */
|
|
5273
|
+
const revert = (): void => {
|
|
5274
|
+
setSaveFailed(null)
|
|
5275
|
+
setEdit(prev => ({ ...prev, buffer: prev.baseText }))
|
|
5276
|
+
}
|
|
5277
|
+
|
|
5278
|
+
/**
|
|
5279
|
+
* A layer tab is one click away from dropping the buffer: with unsaved
|
|
5280
|
+
* edits the click asks first, and only the dialog's answer switches.
|
|
5281
|
+
*/
|
|
5282
|
+
const switchLayer = (next: SideLayer): void => {
|
|
5283
|
+
if (next === layer) return
|
|
5284
|
+
if (dirty) {
|
|
5285
|
+
setPendingLayer(next)
|
|
5286
|
+
return
|
|
5287
|
+
}
|
|
5288
|
+
setLayer(next)
|
|
5289
|
+
}
|
|
5290
|
+
const confirmLeave = (): void => {
|
|
5291
|
+
const next = pendingLayer
|
|
5292
|
+
setPendingLayer(null)
|
|
5293
|
+
if (next !== null) setLayer(next)
|
|
5294
|
+
}
|
|
5295
|
+
|
|
5296
|
+
/** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
|
|
5297
|
+
const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
5298
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
5299
|
+
event.preventDefault()
|
|
5300
|
+
if (dirty && !saving) void runSave(edit.baseSha)
|
|
5301
|
+
}
|
|
5302
|
+
// F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
|
|
5303
|
+
// Alt+Arrow because CodeMirror's default keymap binds those to moving a
|
|
5304
|
+
// line, and the armed editor lives inside this same pane.
|
|
5305
|
+
if (event.key === 'F7') {
|
|
5306
|
+
event.preventDefault()
|
|
5307
|
+
goToChange(event.shiftKey ? -1 : 1)
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
|
|
5311
|
+
/**
|
|
5312
|
+
* One bubbling hover listener turns the cell under the pointer into its
|
|
5313
|
+
* block id: every changed row's code cells carry `data-block`, so `closest`
|
|
5314
|
+
* reads the block off whatever the pointer is over — no handler per cell,
|
|
5315
|
+
* and a pointer over context or a gutter simply clears the hot block.
|
|
5316
|
+
*/
|
|
5317
|
+
/**
|
|
5318
|
+
* The divider: a ratio, not a pixel width, so the columns keep their
|
|
5319
|
+
* proportion when the drawer itself is resized.
|
|
5320
|
+
*
|
|
5321
|
+
* Clamped well short of either edge — a column dragged to nothing looks
|
|
5322
|
+
* like a broken pane, and there is no affordance to drag it back out of.
|
|
5323
|
+
*/
|
|
5324
|
+
const onSplitDrag = (clientX: number): void => {
|
|
5325
|
+
const box = colsRef.current?.getBoundingClientRect()
|
|
5326
|
+
if (box === undefined || box.width === 0) return
|
|
5327
|
+
const ratio = (clientX - box.left) / box.width
|
|
5328
|
+
setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
|
|
5329
|
+
}
|
|
5330
|
+
|
|
5331
|
+
const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
|
5332
|
+
const hit = (event.target as Element).closest('[data-block]')
|
|
5333
|
+
const id = hit === null ? null : Number(hit.getAttribute('data-block'))
|
|
5334
|
+
setHotBlock(prev => (prev === id ? prev : id))
|
|
5335
|
+
}
|
|
5336
|
+
|
|
5337
|
+
/**
|
|
5338
|
+
* Run one block action with the coordinates of the diff on screen.
|
|
5339
|
+
*
|
|
5340
|
+
* Discard never acts from the click — the drawer opens the confirmation,
|
|
5341
|
+
* and the confirmed call carries this same snapshot, so a file that moved
|
|
5342
|
+
* underneath the dialog is refused host-side rather than re-derived from
|
|
5343
|
+
* whatever the poll has fetched since. Stage and unstage run now; the
|
|
5344
|
+
* clicked block's buttons stay disabled until the answer lands, and the
|
|
5345
|
+
* drawer's op lock refuses any other block click meanwhile.
|
|
5346
|
+
*/
|
|
5347
|
+
const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
|
|
5348
|
+
if (sides === null) return
|
|
5349
|
+
const ask: BlockAsk = {
|
|
5350
|
+
path, layer, diffSha: sides.diffSha,
|
|
5351
|
+
lines: blockLines(rows, block),
|
|
5352
|
+
...blockTally(rows, block),
|
|
5353
|
+
wholeFile: blockIsWholeFile(rows, block),
|
|
5354
|
+
}
|
|
5355
|
+
if (mode === 'discard') {
|
|
5356
|
+
void onBlockAction(mode, ask)
|
|
5357
|
+
return
|
|
5358
|
+
}
|
|
5359
|
+
setPendingBlock(block)
|
|
5360
|
+
try {
|
|
5361
|
+
await onBlockAction(mode, ask)
|
|
5362
|
+
} finally {
|
|
5363
|
+
setPendingBlock(null)
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
|
|
5367
|
+
/** The pane the drawer had before this view existed, notice included. */
|
|
5368
|
+
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
5369
|
+
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
|
|
5370
|
+
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
5371
|
+
|
|
5372
|
+
if (failed) return unifiedFallback()
|
|
5373
|
+
if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
|
|
5374
|
+
if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
|
|
5375
|
+
if (sides.tooLarge) {
|
|
5376
|
+
return (
|
|
5377
|
+
<>
|
|
5378
|
+
<div className={css.sideNotice}>{t('diffTooLarge')}</div>
|
|
5379
|
+
{unifiedFallback()}
|
|
5380
|
+
</>
|
|
5381
|
+
)
|
|
5382
|
+
}
|
|
5383
|
+
// The tabs are the pane's, not one layer's: an empty diff here (a fully
|
|
5384
|
+
// staged file's unstaged side, a file with nothing staged) is one click from
|
|
5385
|
+
// the other layer, and the Edit button still arms — the working tree is the
|
|
5386
|
+
// edit target even when every change in it is already staged. So the
|
|
5387
|
+
// no-change treatment below is a state of the BODY (`sideBodyState`), never
|
|
5388
|
+
// an early return for the pane: returning here is what used to blank the
|
|
5389
|
+
// tabs for exactly these files.
|
|
5390
|
+
const bodyState = sideBodyState(rows, editable)
|
|
5391
|
+
/**
|
|
5392
|
+
* How many separate places this file changed — the count the nav walks.
|
|
5393
|
+
*
|
|
5394
|
+
* Offered only for the read view. While armed, the left column is the index
|
|
5395
|
+
* side rendered DENSE and the right one is a buffer whose line count has
|
|
5396
|
+
* already diverged from it, so scrolling to a block would put the change at
|
|
5397
|
+
* the top of one column and unrelated code at the top of the other.
|
|
5398
|
+
*/
|
|
5399
|
+
const changes = bodyState.kind === 'rows' ? blockCount(rows) : 0
|
|
5400
|
+
// The hovered block's first row hosts the action bar; a del-only block has
|
|
5401
|
+
// no right cell, so its bar rides the left one instead. In the editor
|
|
5402
|
+
// layout the left column is dense, so the bar rides its first left row.
|
|
5403
|
+
const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
|
|
5404
|
+
const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
|
|
5405
|
+
// Dirty buffer, no block actions: a patch computed from the loaded diff
|
|
5406
|
+
// would land on top of edits the patch knows nothing about.
|
|
5407
|
+
const barDisabled = pendingBlock !== null || dirty
|
|
5408
|
+
const blockBar = (block: number): ReactNode => (
|
|
5409
|
+
<span className={css.blockBar}>
|
|
5410
|
+
{layer === 'staged' ? (
|
|
5411
|
+
<button
|
|
5412
|
+
type="button"
|
|
5413
|
+
className={css.blockBtn}
|
|
5414
|
+
disabled={barDisabled}
|
|
5415
|
+
onClick={() => { void runBlock('unstage', block) }}
|
|
5416
|
+
>{t('blockUnstage')}</button>
|
|
5417
|
+
) : (
|
|
5418
|
+
<>
|
|
5419
|
+
<button
|
|
5420
|
+
type="button"
|
|
5421
|
+
className={css.blockBtn}
|
|
5422
|
+
disabled={barDisabled}
|
|
5423
|
+
onClick={() => { void runBlock('stage', block) }}
|
|
5424
|
+
>{t('blockStage')}</button>
|
|
5425
|
+
<button
|
|
5426
|
+
type="button"
|
|
5427
|
+
className={`${css.blockBtn} ${css.blockBtnDanger}`}
|
|
5428
|
+
disabled={barDisabled}
|
|
5429
|
+
onClick={() => { void runBlock('discard', block) }}
|
|
5430
|
+
>{t('blockDiscard')}</button>
|
|
5431
|
+
</>
|
|
5432
|
+
)}
|
|
5433
|
+
</span>
|
|
5434
|
+
)
|
|
5435
|
+
/** Clicking the working-tree column is the arm gesture readers will try
|
|
5436
|
+
* first — unless the click was really a text selection, or landed on a
|
|
5437
|
+
* block button, in which case it keeps its own meaning. */
|
|
5438
|
+
const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
|
|
5439
|
+
if (edit.armed) return
|
|
5440
|
+
if ((event.target as Element).closest('button') !== null) return
|
|
5441
|
+
const selection = window.getSelection()
|
|
5442
|
+
if (selection !== null && !selection.isCollapsed) return
|
|
5443
|
+
arm()
|
|
5444
|
+
}
|
|
5445
|
+
return (
|
|
5446
|
+
/* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
|
|
5447
|
+
this element, so it only sees keys whose target is inside it — and
|
|
5448
|
+
clicking diff text, which is not focusable, otherwise leaves focus on
|
|
5449
|
+
the document body and the key never arrives. A negative index keeps the
|
|
5450
|
+
pane out of the tab order while letting a click land focus here. */
|
|
5451
|
+
<div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
|
|
5452
|
+
<div className={css.sideTabs}>
|
|
5453
|
+
<button
|
|
5454
|
+
type="button"
|
|
5455
|
+
aria-pressed={layer === 'unstaged'}
|
|
5456
|
+
className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
5457
|
+
onClick={() => switchLayer('unstaged')}
|
|
5458
|
+
>{t('tabUnstaged')}</button>
|
|
5459
|
+
<button
|
|
5460
|
+
type="button"
|
|
5461
|
+
aria-pressed={layer === 'staged'}
|
|
5462
|
+
className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
5463
|
+
onClick={() => switchLayer('staged')}
|
|
5464
|
+
>{t('tabStaged')}</button>
|
|
5465
|
+
{/* A file whose whole delta is one line is unfindable by scrolling:
|
|
5466
|
+
the tint only shows once you are already looking at it. The row
|
|
5467
|
+
model knows where every change is, so these two say so. The count
|
|
5468
|
+
is the other half of the answer — "there is one place to look" is
|
|
5469
|
+
what stops the hunt. */}
|
|
5470
|
+
{changes > 0 ? (
|
|
5471
|
+
<span className={css.sideNav}>
|
|
5472
|
+
<button
|
|
5473
|
+
type="button"
|
|
5474
|
+
className={css.blockBtn}
|
|
5475
|
+
title={t('prevChangeHint')}
|
|
5476
|
+
aria-label={t('prevChange')}
|
|
5477
|
+
onClick={() => { goToChange(-1) }}
|
|
5478
|
+
><NavGlyph of="prev" /></button>
|
|
5479
|
+
<button
|
|
5480
|
+
type="button"
|
|
5481
|
+
className={css.blockBtn}
|
|
5482
|
+
title={t('nextChangeHint')}
|
|
5483
|
+
aria-label={t('nextChange')}
|
|
5484
|
+
onClick={() => { goToChange(1) }}
|
|
5485
|
+
><NavGlyph of="next" /></button>
|
|
5486
|
+
<span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
|
|
5487
|
+
</span>
|
|
5488
|
+
) : null}
|
|
5489
|
+
{/* Editing arms explicitly and saves explicitly — the two halves of
|
|
5490
|
+
"never per keystroke". Save enables only while dirty; Revert drops
|
|
5491
|
+
the buffer back onto its basis without touching the file. A
|
|
5492
|
+
payload the CRLF gate refuses offers no Edit button at all — the
|
|
5493
|
+
notice below says why rather than leaving a button that does
|
|
5494
|
+
nothing. */}
|
|
5495
|
+
{layer === 'unstaged' ? (
|
|
5496
|
+
<span className={css.sideActions}>
|
|
5497
|
+
{edit.armed ? (
|
|
5498
|
+
<>
|
|
5499
|
+
<button
|
|
5500
|
+
type="button"
|
|
5501
|
+
className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
|
|
5502
|
+
disabled={!dirty || saving}
|
|
5503
|
+
onClick={() => { void runSave(edit.baseSha) }}
|
|
5504
|
+
>{t('fileSave')}</button>
|
|
5505
|
+
<button
|
|
5506
|
+
type="button"
|
|
5507
|
+
className={css.blockBtn}
|
|
5508
|
+
disabled={!dirty || saving}
|
|
5509
|
+
onClick={revert}
|
|
5510
|
+
>{t('fileRevert')}</button>
|
|
5511
|
+
</>
|
|
5512
|
+
) : armable ? (
|
|
5513
|
+
<button type="button" className={css.blockBtn} onClick={arm}>{t('editFile')}</button>
|
|
5514
|
+
) : null}
|
|
5515
|
+
</span>
|
|
5516
|
+
) : null}
|
|
5517
|
+
</div>
|
|
5518
|
+
{dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
|
|
5519
|
+
{layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
|
|
5520
|
+
{/* §4's row: the file moved underneath a dirty buffer — by the poll's
|
|
5521
|
+
notice or by a refused save — and the reader chooses which version
|
|
5522
|
+
survives. Overwrite waits for the refetch the refusal triggered, so
|
|
5523
|
+
it is checked against the file as it truly stands. */}
|
|
5524
|
+
{dirty && edit.conflict ? (
|
|
5525
|
+
<div className={css.sideBanner} role="alert">
|
|
5526
|
+
<span className={css.sideBannerTitle}>{t('staleTitle')}</span>
|
|
5527
|
+
<span>{t('staleBody')}</span>
|
|
5528
|
+
<span className={css.sideBannerActs}>
|
|
5529
|
+
<button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
|
|
5530
|
+
<button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
|
|
5531
|
+
</span>
|
|
5532
|
+
</div>
|
|
5533
|
+
) : null}
|
|
5534
|
+
{saveFailed !== null ? (
|
|
5535
|
+
<div className={css.sideBanner} role="alert">
|
|
5536
|
+
<span className={css.sideBannerTitle}>{saveFailed.title}</span>
|
|
5537
|
+
{saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
|
|
5538
|
+
<span className={css.sideBannerActs}>
|
|
5539
|
+
<button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
|
|
5540
|
+
</span>
|
|
5541
|
+
</div>
|
|
5542
|
+
) : null}
|
|
5543
|
+
{/* Two columns that scroll sideways independently, with a divider the
|
|
5544
|
+
reader can drag. One grid spanning both sides could not do this: its
|
|
5545
|
+
tracks are sized by the widest line in the file, so a drag moved
|
|
5546
|
+
nothing on exactly the wide files where the space matters. Vertical
|
|
5547
|
+
alignment survives the split because both columns render one row per
|
|
5548
|
+
aligned row at the same line height — the diff decides the rows, the
|
|
5549
|
+
layout only decides how much width each side gets. */}
|
|
5550
|
+
<div ref={scrollRef} className={css.sideScroll}>
|
|
5551
|
+
{bodyState.kind === 'empty' ? (
|
|
5552
|
+
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
5553
|
+
) : (
|
|
5554
|
+
<div
|
|
5555
|
+
ref={colsRef}
|
|
5556
|
+
className={css.sideCols}
|
|
5557
|
+
onMouseOver={onBodyHover}
|
|
5558
|
+
onMouseLeave={() => { setHotBlock(null) }}
|
|
5559
|
+
>
|
|
5560
|
+
<div className={css.sideCol} style={{ flexBasis: `${split * 100}%` }}>
|
|
5561
|
+
<div className={css.sideColGrid}>
|
|
5562
|
+
{bodyState.kind === 'editor' ? (
|
|
5563
|
+
/* While armed the left column renders the index side DENSE —
|
|
5564
|
+
one row per index line, no diff holes — because the right
|
|
5565
|
+
column is a buffer whose line count diverges from the diff
|
|
5566
|
+
the moment a keystroke lands. */
|
|
5567
|
+
leftRows.map((entry, k) => {
|
|
5568
|
+
const { row, i } = entry
|
|
5569
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
5570
|
+
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5571
|
+
return (
|
|
5572
|
+
<Fragment key={`l${i}`}>
|
|
5573
|
+
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
|
|
5574
|
+
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
|
|
5575
|
+
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
5576
|
+
{hot && k === hotFirstLeft ? blockBar(row.block) : null}
|
|
5577
|
+
</span>
|
|
5578
|
+
</Fragment>
|
|
5579
|
+
)
|
|
5580
|
+
})
|
|
5581
|
+
) : (
|
|
5582
|
+
rows.map((row, i) => {
|
|
5583
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
5584
|
+
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5585
|
+
// The block's action bar rides in this column only for a row
|
|
5586
|
+
// with no right-hand side — a pure deletion, where the right
|
|
5587
|
+
// column has no cell to hang it on.
|
|
5588
|
+
const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
|
|
5589
|
+
return (
|
|
5590
|
+
<Fragment key={i}>
|
|
5591
|
+
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
|
|
5592
|
+
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
|
|
5593
|
+
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
5594
|
+
{bar}
|
|
5595
|
+
</span>
|
|
5596
|
+
</Fragment>
|
|
5597
|
+
)
|
|
5598
|
+
})
|
|
5599
|
+
)}
|
|
5600
|
+
</div>
|
|
5601
|
+
</div>
|
|
5602
|
+
<PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
|
|
5603
|
+
<div className={`${css.sideCol} ${css.sideColRight}`}>
|
|
5604
|
+
{bodyState.kind === 'editor' ? (
|
|
5605
|
+
<CodeEditor
|
|
5606
|
+
value={edit.buffer}
|
|
5607
|
+
original={indexText}
|
|
5608
|
+
onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
|
|
5609
|
+
syntax={editSyntax}
|
|
5610
|
+
indent={indentOfBuffer}
|
|
5611
|
+
ariaLabel={path}
|
|
5612
|
+
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
|
5613
|
+
/>
|
|
5614
|
+
) : (
|
|
5615
|
+
<div className={css.sideColGrid}>
|
|
5616
|
+
{rows.map((row, i) => {
|
|
5617
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
5618
|
+
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5619
|
+
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
5620
|
+
return (
|
|
5621
|
+
<Fragment key={i}>
|
|
5622
|
+
<span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
|
|
5623
|
+
<span
|
|
5624
|
+
className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
|
|
5625
|
+
data-block={row.block >= 0 ? row.block : undefined}
|
|
5626
|
+
onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
|
|
5627
|
+
>
|
|
5628
|
+
{renderSideCode(row.right, rightSyntax?.[i])}
|
|
5629
|
+
{bar}
|
|
5630
|
+
</span>
|
|
5631
|
+
</Fragment>
|
|
5632
|
+
)
|
|
5633
|
+
})}
|
|
5634
|
+
</div>
|
|
5635
|
+
)}
|
|
5636
|
+
</div>
|
|
5637
|
+
</div>
|
|
5638
|
+
)}
|
|
5639
|
+
</div>
|
|
5640
|
+
{pendingLayer !== null ? (
|
|
5641
|
+
<LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
|
|
5642
|
+
) : null}
|
|
5643
|
+
</div>
|
|
5644
|
+
)
|
|
5645
|
+
}
|
|
5646
|
+
|
|
5647
|
+
/**
|
|
5648
|
+
* The unsaved-edits guard, rendered at both sites that defer a gesture on the
|
|
5649
|
+
* buffer's answer: the pane's layer-tab switch, and the drawer level (file
|
|
5650
|
+
* selection, main tab, source switch, close) for every gesture that would
|
|
5651
|
+
* drop the buffer. Same reason as the roll-back confirmation — the click it
|
|
5652
|
+
* answers to is one gesture away from losing work. Cancel holds the initial
|
|
5653
|
+
* focus and Escape closes, because the default answer to losing edits is no.
|
|
5654
|
+
*/
|
|
5655
|
+
function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
|
|
5656
|
+
t: Translate
|
|
5657
|
+
path: string
|
|
5658
|
+
onCancel: () => void
|
|
5659
|
+
onConfirm: () => void
|
|
5660
|
+
}): ReactNode {
|
|
5661
|
+
const stayRef = useRef<HTMLButtonElement>(null)
|
|
5662
|
+
useEffect(() => { stayRef.current?.focus() }, [])
|
|
5663
|
+
useEffect(() => {
|
|
5664
|
+
// Capture phase, like the roll-back dialog: while a question about edits
|
|
5665
|
+
// is open, Escape answers it and nothing else — consumed here, before it
|
|
5666
|
+
// can reach the page's other Escape handlers (an open picker's dismiss,
|
|
5667
|
+
// the commit box's undo).
|
|
5668
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
5669
|
+
if (event.key !== 'Escape') return
|
|
5670
|
+
event.stopPropagation()
|
|
5671
|
+
onCancel()
|
|
5672
|
+
}
|
|
5673
|
+
window.addEventListener('keydown', onKey, true)
|
|
5674
|
+
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
5675
|
+
}, [onCancel])
|
|
5676
|
+
return (
|
|
5677
|
+
<div className={css.confirmScrim} onClick={onCancel}>
|
|
5678
|
+
<div
|
|
5679
|
+
className={css.confirmBox}
|
|
5680
|
+
role="alertdialog"
|
|
5681
|
+
aria-modal="true"
|
|
5682
|
+
aria-label={t('unsavedTitle')}
|
|
5683
|
+
onClick={event => event.stopPropagation()}
|
|
5684
|
+
>
|
|
5685
|
+
<div className={css.confirmTitle}>{t('unsavedTitle')}</div>
|
|
5686
|
+
<div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
|
|
5687
|
+
<div className={css.confirmActions}>
|
|
5688
|
+
<button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
|
|
5689
|
+
<button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
|
|
5690
|
+
</div>
|
|
5691
|
+
</div>
|
|
5692
|
+
</div>
|
|
5693
|
+
)
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
5697
|
+
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
5698
|
+
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
5699
|
+
function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
5700
|
+
const cell = side === 'left' ? row.left : row.right
|
|
5701
|
+
if (cell === null || row.kind === 'same') return css.sideNum
|
|
5702
|
+
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
5703
|
+
}
|
|
5704
|
+
|
|
5705
|
+
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
5706
|
+
function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
5707
|
+
const cell = side === 'left' ? row.left : row.right
|
|
5708
|
+
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
5709
|
+
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
/** One cell's Shiki runs, or its plain text when no tokens exist. */
|
|
5713
|
+
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
5714
|
+
if (cell === null) return ''
|
|
5715
|
+
if (tokens === undefined || tokens.length === 0) return cell.text
|
|
5716
|
+
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
|
|
5717
|
+
return tokens.map((tok, i) => (
|
|
5718
|
+
<span
|
|
5719
|
+
key={i}
|
|
5720
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
5721
|
+
>{tok.text}</span>
|
|
5722
|
+
))
|
|
5723
|
+
}
|
|
5724
|
+
|
|
4432
5725
|
/* ---------- shared helpers ---------- */
|
|
4433
5726
|
|
|
4434
5727
|
/** Split a combined `git diff` into path -> its segment text. */
|