@young1lin/dsh-ui-gitworkbench 0.1.4 → 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 +42 -0
- package/CHANGELOG_EN.md +42 -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 +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- 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 +1465 -166
- 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/discard-flow.ts +82 -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 +76 -9
- 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/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- 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,10 +56,27 @@ 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'
|
|
62
78
|
import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
|
|
79
|
+
import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
|
|
63
80
|
import { filterFiles } from './file-filter.ts'
|
|
64
81
|
import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
|
|
65
82
|
import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
|
|
@@ -70,8 +87,8 @@ import {
|
|
|
70
87
|
fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
|
|
71
88
|
type CheckState, type Tick, type TickAction,
|
|
72
89
|
} from './stage-tree.ts'
|
|
73
|
-
import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
74
|
-
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'
|
|
75
92
|
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
76
93
|
import type { WorkbenchKey } from './locales.ts'
|
|
77
94
|
import css from './GitWorkbenchPanel.module.css'
|
|
@@ -185,10 +202,12 @@ export interface SyncStatus {
|
|
|
185
202
|
readonly hasRemote: boolean
|
|
186
203
|
}
|
|
187
204
|
|
|
188
|
-
/** 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. */
|
|
189
208
|
export type GitOpFailure =
|
|
190
209
|
| 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
|
|
191
|
-
| 'nothing-to-commit' | 'dirty' | 'unknown'
|
|
210
|
+
| 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
|
|
192
211
|
|
|
193
212
|
export interface GitOpResult {
|
|
194
213
|
readonly ok: boolean
|
|
@@ -199,39 +218,145 @@ export interface GitOpResult {
|
|
|
199
218
|
}
|
|
200
219
|
|
|
201
220
|
/** The host endpoints under `gitWorkbench/` that change something. */
|
|
202
|
-
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile'
|
|
221
|
+
export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
|
|
203
222
|
|
|
204
223
|
/** Extra arguments an operation needs beyond the worktree path. */
|
|
205
224
|
export interface GitOpPayload {
|
|
206
225
|
readonly paths?: readonly string[]
|
|
207
226
|
readonly message?: string
|
|
208
227
|
readonly amend?: boolean
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*
|
|
212
|
-
|
|
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. */
|
|
213
235
|
readonly path?: string
|
|
214
236
|
/** `discardFile` only: the effect the confirmation stated. The host refuses
|
|
215
237
|
* if the file changed underneath the dialog and now means something else. */
|
|
216
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[]
|
|
217
245
|
}
|
|
218
246
|
|
|
219
|
-
|
|
220
|
-
export
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
225
341
|
readonly error?: string
|
|
226
342
|
}
|
|
227
343
|
|
|
228
344
|
/** Translate a key of this plugin's namespace, with optional `{name}` params. */
|
|
229
|
-
type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
345
|
+
export type Translate = (key: string, params?: Record<string, string | number>) => string
|
|
230
346
|
|
|
231
347
|
type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
232
348
|
readonly t: Translate
|
|
233
349
|
readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
234
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. */
|
|
235
360
|
readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
|
|
236
361
|
/** Binding only, no git — the probe the shut chip can afford to poll. */
|
|
237
362
|
readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
|
|
@@ -249,7 +374,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
|
249
374
|
readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
|
|
250
375
|
/** What rolling this file back WOULD do, read fresh so the confirmation
|
|
251
376
|
* states the real consequence rather than one derived from a polled row. */
|
|
252
|
-
readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<
|
|
377
|
+
readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
|
|
253
378
|
}
|
|
254
379
|
|
|
255
380
|
/**
|
|
@@ -258,7 +383,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
|
258
383
|
* modes with a back action, so returning to the working tree is always one click
|
|
259
384
|
* (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
|
|
260
385
|
*/
|
|
261
|
-
type Tab = 'changes' | 'history' | 'compare'
|
|
386
|
+
type Tab = 'changes' | 'history' | 'compare' | 'files'
|
|
262
387
|
|
|
263
388
|
/** How many further commits one page request loads. */
|
|
264
389
|
const HISTORY_PAGE = 30
|
|
@@ -271,6 +396,11 @@ const MIN_DRAWER_WIDTH = 760
|
|
|
271
396
|
const MIN_COMMITS_WIDTH = 190
|
|
272
397
|
const MIN_TREE_WIDTH = 170
|
|
273
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
|
|
274
404
|
|
|
275
405
|
/** Longest edge a chosen background image is resampled to before storage. Past
|
|
276
406
|
* this the file grows fast while a blurred backdrop gains nothing. */
|
|
@@ -289,14 +419,31 @@ const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
|
|
|
289
419
|
const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
|
|
290
420
|
const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
|
|
291
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'
|
|
292
426
|
|
|
293
|
-
/** Dragged pane
|
|
427
|
+
/** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
|
|
294
428
|
interface PaneWidths {
|
|
295
429
|
readonly commits: number | null
|
|
296
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
|
|
297
440
|
}
|
|
298
441
|
|
|
299
|
-
|
|
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 }
|
|
300
447
|
|
|
301
448
|
/**
|
|
302
449
|
* @param value - value read back from storage.
|
|
@@ -304,9 +451,9 @@ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
|
|
|
304
451
|
*/
|
|
305
452
|
function isPaneWidths(value: unknown): value is PaneWidths {
|
|
306
453
|
if (typeof value !== 'object' || value === null) return false
|
|
307
|
-
const { commits, tree } = value as Partial<PaneWidths>
|
|
454
|
+
const { commits, tree, commitsTall } = value as Partial<PaneWidths>
|
|
308
455
|
const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
|
|
309
|
-
return ok(commits) && ok(tree)
|
|
456
|
+
return ok(commits) && ok(tree) && (commitsTall === undefined || ok(commitsTall))
|
|
310
457
|
}
|
|
311
458
|
|
|
312
459
|
/**
|
|
@@ -355,24 +502,26 @@ function writeStored(key: string, value: unknown): void {
|
|
|
355
502
|
* crosses a child that stops propagation.
|
|
356
503
|
* @returns the active flag for styling, and the pointerdown handler to attach.
|
|
357
504
|
*/
|
|
358
|
-
function
|
|
505
|
+
function usePaneDrag(axis: 'x' | 'y' = 'x'): {
|
|
359
506
|
dragging: boolean
|
|
360
|
-
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (
|
|
507
|
+
start: (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void) => void
|
|
361
508
|
} {
|
|
509
|
+
const along = (event: { clientX: number; clientY: number }): number =>
|
|
510
|
+
axis === 'y' ? event.clientY : event.clientX
|
|
362
511
|
const [dragging, setDragging] = useState(false)
|
|
363
|
-
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (
|
|
512
|
+
const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void): void => {
|
|
364
513
|
event.preventDefault()
|
|
365
514
|
const handle = event.currentTarget
|
|
366
515
|
handle.setPointerCapture(event.pointerId)
|
|
367
516
|
setDragging(true)
|
|
368
|
-
const onMove = (move: PointerEvent): void => { onDrag(move
|
|
517
|
+
const onMove = (move: PointerEvent): void => { onDrag(along(move), false) }
|
|
369
518
|
// `pointercancel` ends a drag the browser took over (a touch became a
|
|
370
519
|
// gesture, the window lost focus). It releases capture itself, so only the
|
|
371
520
|
// pointerup path releases — and both must detach, or the next drag stacks a
|
|
372
521
|
// second set of listeners on the same handle.
|
|
373
522
|
const finish = (end: PointerEvent): void => {
|
|
374
523
|
if (end.type === 'pointerup') {
|
|
375
|
-
onDrag(end
|
|
524
|
+
onDrag(along(end), true)
|
|
376
525
|
handle.releasePointerCapture(end.pointerId)
|
|
377
526
|
}
|
|
378
527
|
handle.removeEventListener('pointermove', onMove)
|
|
@@ -393,22 +542,34 @@ function useHorizontalDrag(): {
|
|
|
393
542
|
* @param onDrag - receives the pointer's x and whether the drag just ended.
|
|
394
543
|
* @returns the divider element.
|
|
395
544
|
*/
|
|
396
|
-
function PaneDivider({ label, onDrag }: {
|
|
545
|
+
function PaneDivider({ label, onDrag, axis = 'x' }: {
|
|
397
546
|
label: string
|
|
398
|
-
|
|
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'
|
|
399
551
|
}): ReactNode {
|
|
400
|
-
const { dragging, start } =
|
|
552
|
+
const { dragging, start } = usePaneDrag(axis)
|
|
553
|
+
const base = axis === 'y' ? `${css.paneDivider} ${css.paneDividerY}` : css.paneDivider
|
|
401
554
|
return (
|
|
402
555
|
<div
|
|
403
|
-
className={dragging ? `${
|
|
556
|
+
className={dragging ? `${base} ${css.paneDividerActive}` : base}
|
|
404
557
|
role="separator"
|
|
405
|
-
|
|
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'}
|
|
406
561
|
aria-label={label}
|
|
407
562
|
onPointerDown={event => start(event, onDrag)}
|
|
408
563
|
/>
|
|
409
564
|
)
|
|
410
565
|
}
|
|
411
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
|
+
|
|
412
573
|
/** Commit change sets kept in the browser before the least recently used is dropped. */
|
|
413
574
|
const COMMIT_CACHE_CAPACITY = 24
|
|
414
575
|
|
|
@@ -419,6 +580,19 @@ const EMPTY_STATS: WorkbenchStats = {
|
|
|
419
580
|
files: [], diff: '', commits: [],
|
|
420
581
|
}
|
|
421
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
|
+
|
|
422
596
|
/** The overlay with nothing on it — one instance, so an empty overlay never
|
|
423
597
|
* re-renders the tree that receives it. */
|
|
424
598
|
const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
|
|
@@ -454,7 +628,7 @@ const STATUS_BADGE: Record<GitFileStatus, string> = {
|
|
|
454
628
|
renamed: css.stRenamed, deleted: css.stDeleted,
|
|
455
629
|
}
|
|
456
630
|
|
|
457
|
-
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) {
|
|
458
632
|
const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
|
|
459
633
|
state?.byId?.[sessionId]?.cwd) as string | undefined
|
|
460
634
|
/** Whether the session's agent has a turn in flight — the store mirrors it
|
|
@@ -473,6 +647,34 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
473
647
|
const [gen, setGen] = useState(0)
|
|
474
648
|
/** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
|
|
475
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])
|
|
476
678
|
/** Binding + the repository's worktrees, null until the first successful fetch. */
|
|
477
679
|
const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
|
|
478
680
|
/** Worktree the drawer reads, by absolute path. null = follow the session's own
|
|
@@ -843,8 +1045,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
843
1045
|
setPendingTicks(EMPTY_TICKS)
|
|
844
1046
|
setStats({
|
|
845
1047
|
...EMPTY_STATS,
|
|
846
|
-
|
|
847
|
-
|
|
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) ?? '',
|
|
848
1052
|
})
|
|
849
1053
|
}, [statsPath])
|
|
850
1054
|
|
|
@@ -860,8 +1064,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
860
1064
|
const prevBinding = lastBindingRef.current
|
|
861
1065
|
lastBindingRef.current = bindingPath
|
|
862
1066
|
if (prevBinding === bindingPath) return
|
|
863
|
-
const prevSource = prevBinding ?? worktreePath
|
|
864
|
-
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, '/')) {
|
|
865
1069
|
setSourcePath(null)
|
|
866
1070
|
}
|
|
867
1071
|
}, [bindingPath, sourcePath, worktreePath])
|
|
@@ -1020,9 +1224,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1020
1224
|
// made the drawer shake on every tick — and a tick's outcome is already
|
|
1021
1225
|
// visible in place, in the box the user just clicked. So: the old banner
|
|
1022
1226
|
// stays while the op runs (it still describes the last outcome), a
|
|
1023
|
-
// successful stage/unstage
|
|
1024
|
-
//
|
|
1025
|
-
|
|
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)
|
|
1026
1231
|
else setOpResult({ op, result })
|
|
1027
1232
|
return result
|
|
1028
1233
|
} finally {
|
|
@@ -1032,6 +1237,19 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1032
1237
|
}
|
|
1033
1238
|
}
|
|
1034
1239
|
|
|
1240
|
+
/**
|
|
1241
|
+
* Put a failure the drawer produced itself into the same banner git failures
|
|
1242
|
+
* use.
|
|
1243
|
+
*
|
|
1244
|
+
* Roll-back is the caller: it asks the host what a file's roll-back would do
|
|
1245
|
+
* before it does anything, and that question can fail on its own, with no
|
|
1246
|
+
* `runOp` behind it to report through. Everything else the drawer does is
|
|
1247
|
+
* either a git call or has a visible result of its own.
|
|
1248
|
+
*/
|
|
1249
|
+
const reportOpError = (op: GitOpName, error: string): void => {
|
|
1250
|
+
setOpResult({ op, result: { ok: false, failure: 'unknown', error } })
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1035
1253
|
/** Wait for the git lock, so a queued tick batch waits out a heavy
|
|
1036
1254
|
* operation instead of being refused by it. */
|
|
1037
1255
|
const waitNotBusy = async (): Promise<void> => {
|
|
@@ -1165,11 +1383,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1165
1383
|
* @param measured - the drawer's inner width and the panes' current widths.
|
|
1166
1384
|
* @param persist - whether to store it; false for intermediate drag frames.
|
|
1167
1385
|
*/
|
|
1168
|
-
const applyPane = (which:
|
|
1386
|
+
const applyPane = (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
|
|
1169
1387
|
const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
|
|
1170
1388
|
const other = which === 'commits' ? measured.tree : measured.commits
|
|
1171
|
-
const
|
|
1172
|
-
const clamped = Math.min(Math.max(next, min), max)
|
|
1389
|
+
const clamped = clampPane(next, min, measured.drawer, other, MIN_DIFF_WIDTH)
|
|
1173
1390
|
setPanes(prev => {
|
|
1174
1391
|
const updated = { ...prev, [which]: clamped }
|
|
1175
1392
|
if (persist) writeStored(STORE_PANES, updated)
|
|
@@ -1177,6 +1394,29 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1177
1394
|
})
|
|
1178
1395
|
}
|
|
1179
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
|
+
|
|
1180
1420
|
/**
|
|
1181
1421
|
* Drag the leading edge. Clamped at both ends — below the minimum the three
|
|
1182
1422
|
* panes stop fitting, and past the viewport there is nothing to reveal. The
|
|
@@ -1293,6 +1533,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1293
1533
|
onWidth={applyWidth}
|
|
1294
1534
|
panes={panes}
|
|
1295
1535
|
onPane={applyPane}
|
|
1536
|
+
onCommitsTall={applyCommitsTall}
|
|
1296
1537
|
onClose={() => setOpen(false)}
|
|
1297
1538
|
onRefresh={refresh}
|
|
1298
1539
|
commitDraft={commitDraft}
|
|
@@ -1304,12 +1545,21 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1304
1545
|
opResult={opResult}
|
|
1305
1546
|
runOp={runOp}
|
|
1306
1547
|
fetchDiscardPlan={fetchDiscardPlan}
|
|
1548
|
+
onOpError={reportOpError}
|
|
1307
1549
|
pendingTicks={pendingTicks}
|
|
1308
1550
|
onTick={queueTicks}
|
|
1309
1551
|
fetchFileDiff={fetchDiffForView}
|
|
1552
|
+
fetchFileSides={fetchFileSides}
|
|
1553
|
+
writeChecked={writeChecked}
|
|
1554
|
+
fetchBlame={fetchBlame}
|
|
1555
|
+
fetchFileImage={fetchFileImage}
|
|
1310
1556
|
viewKey={viewKey}
|
|
1311
1557
|
gen={gen}
|
|
1312
1558
|
collapsed={collapsed}
|
|
1559
|
+
filesPlaces={filesPlaces}
|
|
1560
|
+
onFilesPlace={rememberPlace}
|
|
1561
|
+
filesTrees={filesTrees}
|
|
1562
|
+
onFilesTree={rememberTree}
|
|
1313
1563
|
onCollapsedChange={setCollapsed}
|
|
1314
1564
|
/>
|
|
1315
1565
|
) : null}
|
|
@@ -1345,6 +1595,26 @@ function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
|
1345
1595
|
)
|
|
1346
1596
|
}
|
|
1347
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
|
+
|
|
1348
1618
|
/**
|
|
1349
1619
|
* Tree glyph: a root with two working copies hanging off it.
|
|
1350
1620
|
*
|
|
@@ -1474,7 +1744,9 @@ interface DrawerProps {
|
|
|
1474
1744
|
onWidth: (next: number, persist: boolean) => void
|
|
1475
1745
|
/** Dragged pane widths; null on either side keeps that pane's CSS default. */
|
|
1476
1746
|
panes: PaneWidths
|
|
1477
|
-
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
|
|
1478
1750
|
onClose: () => void
|
|
1479
1751
|
onRefresh: () => void
|
|
1480
1752
|
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
@@ -1495,21 +1767,33 @@ interface DrawerProps {
|
|
|
1495
1767
|
/** The last write operation's outcome, or null once a new one starts. */
|
|
1496
1768
|
opResult: { op: GitOpName; result: GitOpResult } | null
|
|
1497
1769
|
runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
|
|
1498
|
-
fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<
|
|
1770
|
+
fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
|
|
1771
|
+
/** Say why an operation the drawer started did nothing. */
|
|
1772
|
+
onOpError: (op: GitOpName, error: string) => void
|
|
1499
1773
|
/** Ticks awaiting their git call, keyed by path — overlaid over the file
|
|
1500
1774
|
* list so the click is on screen before git confirms it. */
|
|
1501
1775
|
pendingTicks: ReadonlyMap<string, TickAction>
|
|
1502
1776
|
/** Queue the git calls for a tick batch. */
|
|
1503
1777
|
onTick: (action: TickAction, paths: readonly string[]) => void
|
|
1504
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>
|
|
1505
1785
|
/** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
|
|
1506
1786
|
viewKey: string
|
|
1507
1787
|
gen: number
|
|
1508
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
|
|
1509
1793
|
onCollapsedChange: (next: Set<string>) => void
|
|
1510
1794
|
}
|
|
1511
1795
|
|
|
1512
|
-
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, 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 {
|
|
1513
1797
|
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1514
1798
|
// stable shape and the panes simply render nothing.
|
|
1515
1799
|
const body = shown ?? EMPTY_STATS
|
|
@@ -1530,6 +1814,20 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1530
1814
|
() => tab === 'history' ? parseLogQuery(historyQuery).paths : NO_PATHS,
|
|
1531
1815
|
[tab, historyQuery],
|
|
1532
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
|
+
)
|
|
1533
1831
|
// A selection the current source no longer lists (e.g. after a source or tab
|
|
1534
1832
|
// switch) falls back to the filtered file, else the first — never a dangling
|
|
1535
1833
|
// highlight. See `active-file.ts` for the order and the reasoning.
|
|
@@ -1566,7 +1864,55 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1566
1864
|
// Reset the on-demand cache when the generation (refresh) advances.
|
|
1567
1865
|
useEffect(() => { setFetched(new Map()) }, [gen])
|
|
1568
1866
|
|
|
1569
|
-
|
|
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))
|
|
1570
1916
|
|
|
1571
1917
|
/**
|
|
1572
1918
|
* Roll-back, in two steps that are deliberately not one.
|
|
@@ -1581,27 +1927,23 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1581
1927
|
* `recover` — a deleted file coming back — shows no dialog at all. It loses
|
|
1582
1928
|
* nothing, and a confirmation in front of a pure gain is how people learn to
|
|
1583
1929
|
* dismiss confirmations without reading them.
|
|
1930
|
+
*
|
|
1931
|
+
* Every other answer is `nextAfterPlan`'s to classify, and the one it exists
|
|
1932
|
+
* for is failure: a plan that never arrives reports, where it used to leave
|
|
1933
|
+
* the reader looking at a button that did nothing.
|
|
1584
1934
|
*/
|
|
1585
1935
|
const askDiscard = (file: GitFile): void => {
|
|
1586
1936
|
setDiscardPending({ file, plan: null })
|
|
1587
1937
|
void (async () => {
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
// settled. Refreshing is the honest answer: the row goes away, which is
|
|
1592
|
-
// both the feedback and the fix. A banner saying "nothing happened"
|
|
1593
|
-
// would leave the row that caused it sitting right there.
|
|
1594
|
-
if (preview === null || preview.effect === undefined) {
|
|
1595
|
-
setDiscardPending(null)
|
|
1596
|
-
onRefresh()
|
|
1938
|
+
const next = nextAfterPlan(await fetchDiscardPlan(statsPath, file.path, new AbortController().signal))
|
|
1939
|
+
if (next.kind === 'confirm') {
|
|
1940
|
+
setDiscardPending({ file, plan: next.plan })
|
|
1597
1941
|
return
|
|
1598
1942
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
}
|
|
1604
|
-
setDiscardPending({ file, plan: preview })
|
|
1943
|
+
setDiscardPending(null)
|
|
1944
|
+
if (next.kind === 'run') void runOp('discardFile', { path: file.path, expectedEffect: next.effect })
|
|
1945
|
+
else if (next.kind === 'refresh') onRefresh()
|
|
1946
|
+
else onOpError('discardFile', next.error)
|
|
1605
1947
|
})()
|
|
1606
1948
|
}
|
|
1607
1949
|
|
|
@@ -1612,10 +1954,43 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1612
1954
|
void runOp('discardFile', { path: pending.file.path, expectedEffect: pending.plan.effect })
|
|
1613
1955
|
}
|
|
1614
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
|
+
|
|
1615
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)
|
|
1616
1991
|
const commitsRef = useRef<HTMLDivElement>(null)
|
|
1617
1992
|
const treeRef = useRef<HTMLDivElement>(null)
|
|
1618
|
-
const edgeDrag =
|
|
1993
|
+
const edgeDrag = usePaneDrag()
|
|
1619
1994
|
|
|
1620
1995
|
/**
|
|
1621
1996
|
* What a pane drag is clamped against: the drawer's inner width and what the
|
|
@@ -1623,18 +1998,27 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1623
1998
|
* resized since the last render.
|
|
1624
1999
|
* @returns the three widths in px.
|
|
1625
2000
|
*/
|
|
1626
|
-
const measurePanes = (): { drawer: number; commits: number; tree: number } =>
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
+
}
|
|
1631
2015
|
|
|
1632
2016
|
/**
|
|
1633
2017
|
* @param which - the pane a divider resizes.
|
|
1634
2018
|
* @param ref - that pane's element, whose left edge the width is measured from.
|
|
1635
2019
|
* @returns a drag handler for {@link PaneDivider}.
|
|
1636
2020
|
*/
|
|
1637
|
-
const paneDrag = (which:
|
|
2021
|
+
const paneDrag = (which: PaneWidthKey, ref: { current: HTMLDivElement | null }) =>
|
|
1638
2022
|
(clientX: number, done: boolean): void => {
|
|
1639
2023
|
const left = ref.current?.getBoundingClientRect().left
|
|
1640
2024
|
if (left === undefined) return
|
|
@@ -1654,6 +2038,34 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1654
2038
|
const paneStyle = (px: number | null): CSSProperties | undefined =>
|
|
1655
2039
|
px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
|
|
1656
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
|
+
|
|
1657
2069
|
// Width and the background's three tunables are inline because both are live
|
|
1658
2070
|
// user values; the stylesheet only says what reads them. The pane floors are
|
|
1659
2071
|
// inline for a different reason: they belong to the drag clamp above, and
|
|
@@ -1665,6 +2077,8 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1665
2077
|
'--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
|
|
1666
2078
|
'--gs-min-tree': `${MIN_TREE_WIDTH}px`,
|
|
1667
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`,
|
|
1668
2082
|
} as CSSProperties,
|
|
1669
2083
|
...maximized || width === null ? {} : { width: `${width}px` },
|
|
1670
2084
|
...background === null ? {} : {
|
|
@@ -1679,7 +2093,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1679
2093
|
className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
|
|
1680
2094
|
data-gs-theme={theme}
|
|
1681
2095
|
data-gs-part="overlay"
|
|
1682
|
-
onClick={
|
|
2096
|
+
onClick={closeDrawer}
|
|
1683
2097
|
>
|
|
1684
2098
|
<div
|
|
1685
2099
|
ref={drawerRef}
|
|
@@ -1711,7 +2125,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1711
2125
|
sessionPath={sessionPath}
|
|
1712
2126
|
statsPath={statsPath}
|
|
1713
2127
|
fallbackBranch={stats.branch}
|
|
1714
|
-
onSwitch={
|
|
2128
|
+
onSwitch={leaveSource}
|
|
1715
2129
|
/>
|
|
1716
2130
|
<Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
|
|
1717
2131
|
{tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
|
|
@@ -1770,7 +2184,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1770
2184
|
type="button"
|
|
1771
2185
|
className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
|
|
1772
2186
|
aria-label={t('close')} title={t('close')}
|
|
1773
|
-
onClick={
|
|
2187
|
+
onClick={closeDrawer}
|
|
1774
2188
|
><ChromeGlyph of="close" /></button>
|
|
1775
2189
|
</div>
|
|
1776
2190
|
</div>
|
|
@@ -1780,22 +2194,29 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1780
2194
|
role="tab"
|
|
1781
2195
|
aria-selected={tab === 'changes'}
|
|
1782
2196
|
className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1783
|
-
onClick={() =>
|
|
2197
|
+
onClick={() => leaveTab('changes')}
|
|
1784
2198
|
>{t('tabChanges')}</button>
|
|
1785
2199
|
<button
|
|
1786
2200
|
type="button"
|
|
1787
2201
|
role="tab"
|
|
1788
2202
|
aria-selected={tab === 'history'}
|
|
1789
2203
|
className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1790
|
-
onClick={() =>
|
|
2204
|
+
onClick={() => leaveTab('history')}
|
|
1791
2205
|
>{t('tabHistory')}</button>
|
|
1792
2206
|
<button
|
|
1793
2207
|
type="button"
|
|
1794
2208
|
role="tab"
|
|
1795
2209
|
aria-selected={tab === 'compare'}
|
|
1796
2210
|
className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
|
|
1797
|
-
onClick={() =>
|
|
2211
|
+
onClick={() => leaveTab('compare')}
|
|
1798
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>
|
|
1799
2220
|
</div>
|
|
1800
2221
|
{tab === 'compare' ? (
|
|
1801
2222
|
<CompareBar
|
|
@@ -1831,12 +2252,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1831
2252
|
role="status"
|
|
1832
2253
|
>{opMessage(t, opResult.op, opResult.result)}</div>
|
|
1833
2254
|
) : null}
|
|
1834
|
-
|
|
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}>
|
|
1835
2263
|
{tab === 'history' ? (
|
|
1836
2264
|
<>
|
|
1837
2265
|
<CommitList
|
|
1838
2266
|
paneRef={commitsRef}
|
|
1839
|
-
style={
|
|
2267
|
+
style={paneTall(panes.commitsTall ?? null)}
|
|
1840
2268
|
t={t}
|
|
1841
2269
|
loading={historyLoading}
|
|
1842
2270
|
commits={commits}
|
|
@@ -1853,9 +2281,35 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1853
2281
|
fetchAuthors={fetchAuthors}
|
|
1854
2282
|
fetchRepoTree={fetchRepoTree}
|
|
1855
2283
|
/>
|
|
1856
|
-
<PaneDivider label={t('resizeCommits')} onDrag={
|
|
2284
|
+
<PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
|
|
1857
2285
|
</>
|
|
1858
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
|
+
<>
|
|
1859
2313
|
<div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
|
|
1860
2314
|
<FileTree
|
|
1861
2315
|
t={t}
|
|
@@ -1911,24 +2365,65 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1911
2365
|
{(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
|
|
1912
2366
|
: activeFile !== null && activeFile.binary ? (
|
|
1913
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
|
+
/>
|
|
1914
2384
|
) : loading && segment.length === 0 ? (
|
|
1915
2385
|
<div className={css.empty}>{t('loadingDiff')}</div>
|
|
1916
2386
|
) : segment.length > 0 ? (
|
|
1917
|
-
<DiffView segment={segment} path={active ?? ''} palette={theme} />
|
|
2387
|
+
<DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
|
|
1918
2388
|
) : (
|
|
1919
2389
|
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
1920
2390
|
)}
|
|
1921
2391
|
</div>
|
|
2392
|
+
</>
|
|
2393
|
+
)}
|
|
2394
|
+
</div>
|
|
1922
2395
|
</div>
|
|
1923
2396
|
{discardPending?.plan != null ? (
|
|
1924
2397
|
<DiscardConfirm
|
|
1925
2398
|
t={t}
|
|
1926
|
-
|
|
1927
|
-
plan={discardPending.plan}
|
|
2399
|
+
body={discardBodyText(t, discardPending.file, discardPending.plan)}
|
|
1928
2400
|
onCancel={() => setDiscardPending(null)}
|
|
1929
2401
|
onConfirm={confirmDiscard}
|
|
1930
2402
|
/>
|
|
1931
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}
|
|
1932
2427
|
</div>
|
|
1933
2428
|
</div>
|
|
1934
2429
|
)
|
|
@@ -1937,28 +2432,28 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
1937
2432
|
/**
|
|
1938
2433
|
* The one dialog in this drawer, because this is the one act it cannot undo.
|
|
1939
2434
|
*
|
|
1940
|
-
* It never asks a generic "are you sure": the
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
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.
|
|
1944
2440
|
*
|
|
1945
2441
|
* There is deliberately no "don't ask again". This is the only path in the
|
|
1946
2442
|
* drawer with nothing behind it, and a checkbox whose whole function is to
|
|
1947
2443
|
* switch off the last guard is a feature that eventually gets clicked.
|
|
1948
2444
|
*/
|
|
1949
|
-
function DiscardConfirm({ t,
|
|
2445
|
+
function DiscardConfirm({ t, body, onCancel, onConfirm }: {
|
|
1950
2446
|
t: Translate
|
|
1951
|
-
|
|
1952
|
-
plan: DiscardPreview
|
|
2447
|
+
body: string
|
|
1953
2448
|
onCancel: () => void
|
|
1954
2449
|
onConfirm: () => void
|
|
1955
2450
|
}): ReactNode {
|
|
1956
2451
|
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
1957
2452
|
useEffect(() => { cancelRef.current?.focus() }, [])
|
|
1958
2453
|
useEffect(() => {
|
|
1959
|
-
// Capture phase:
|
|
1960
|
-
//
|
|
1961
|
-
// 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).
|
|
1962
2457
|
const onKey = (event: KeyboardEvent): void => {
|
|
1963
2458
|
if (event.key !== 'Escape') return
|
|
1964
2459
|
event.stopPropagation()
|
|
@@ -1968,12 +2463,6 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
|
|
|
1968
2463
|
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
1969
2464
|
}, [onCancel])
|
|
1970
2465
|
|
|
1971
|
-
const body = plan.effect === 'delete'
|
|
1972
|
-
? t('discardBodyDelete', { path: file.path })
|
|
1973
|
-
: plan.effect === 'unrename'
|
|
1974
|
-
? t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
|
|
1975
|
-
: t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
|
|
1976
|
-
|
|
1977
2466
|
return (
|
|
1978
2467
|
<div className={css.confirmScrim} onClick={onCancel}>
|
|
1979
2468
|
<div
|
|
@@ -1994,6 +2483,35 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
|
|
|
1994
2483
|
)
|
|
1995
2484
|
}
|
|
1996
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
|
+
|
|
1997
2515
|
/**
|
|
1998
2516
|
* A slash-separated name that gives up its HEAD, never its tail.
|
|
1999
2517
|
*
|
|
@@ -3000,9 +3518,17 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
|
|
|
3000
3518
|
*/
|
|
3001
3519
|
/* ---------- commit graph ---------- */
|
|
3002
3520
|
|
|
3003
|
-
/**
|
|
3004
|
-
*
|
|
3005
|
-
|
|
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
|
|
3006
3532
|
/** Horizontal distance between lanes. */
|
|
3007
3533
|
const GRAPH_LANE_W = 14
|
|
3008
3534
|
/** Ref chips shown inline before the subject; the rest collapse into a "+N". */
|
|
@@ -3155,11 +3681,12 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3155
3681
|
onMouseEnter={show}
|
|
3156
3682
|
onMouseLeave={hide}
|
|
3157
3683
|
>
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
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>
|
|
3163
3690
|
<span className={css.commitSubjectRow}>
|
|
3164
3691
|
{/* Capped at two. A release commit can carry six refs, and the
|
|
3165
3692
|
subject is what the row is actually for — the rest are counted
|
|
@@ -3175,6 +3702,10 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3175
3702
|
<span className={css.commitSubject}>{commit.subject}</span>
|
|
3176
3703
|
{body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
|
|
3177
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>
|
|
3178
3709
|
</button>
|
|
3179
3710
|
</div>
|
|
3180
3711
|
{open && box !== null && host !== null ? createPortal(
|
|
@@ -3268,58 +3799,6 @@ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }:
|
|
|
3268
3799
|
)
|
|
3269
3800
|
}
|
|
3270
3801
|
|
|
3271
|
-
/**
|
|
3272
|
-
* The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
|
|
3273
|
-
* strokes, no fill, rounded joins — outlines, where the old UI shipped filled
|
|
3274
|
-
* silhouettes. Hand-drawn here rather than imported, because the bundle purity
|
|
3275
|
-
* gate forbids an icon package and the drawer needs exactly these two; they
|
|
3276
|
-
* are shapes in that language, not JetBrains' own assets.
|
|
3277
|
-
*
|
|
3278
|
-
* `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
|
|
3279
|
-
* stroke lands on a whole pixel instead of straddling two.
|
|
3280
|
-
*
|
|
3281
|
-
* Every place the drawer names a file or a directory uses these: the path
|
|
3282
|
-
* picker in the history filter, and the file tree behind all three tabs. The
|
|
3283
|
-
* CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
|
|
3284
|
-
* selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
|
|
3285
|
-
*/
|
|
3286
|
-
function PathDirGlyph(): ReactNode {
|
|
3287
|
-
return (
|
|
3288
|
-
<svg
|
|
3289
|
-
className={css.pathDirGlyph}
|
|
3290
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
3291
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
3292
|
-
strokeLinejoin="round" strokeLinecap="round"
|
|
3293
|
-
aria-hidden="true"
|
|
3294
|
-
>
|
|
3295
|
-
{/* Body, with the tab stepping up over the left third. The step is a
|
|
3296
|
-
full 2px: at 1.3px it read as a rounded rectangle with a nick in it
|
|
3297
|
-
rather than a folder. Every straight edge sits on a .5 coordinate so
|
|
3298
|
-
a 1px stroke lands on one pixel instead of straddling two. */}
|
|
3299
|
-
<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" />
|
|
3300
|
-
</svg>
|
|
3301
|
-
)
|
|
3302
|
-
}
|
|
3303
|
-
|
|
3304
|
-
function PathFileGlyph(): ReactNode {
|
|
3305
|
-
return (
|
|
3306
|
-
<svg
|
|
3307
|
-
className={css.pathFileGlyph}
|
|
3308
|
-
width="16" height="16" viewBox="0 0 16 16"
|
|
3309
|
-
fill="none" stroke="currentColor" strokeWidth="1"
|
|
3310
|
-
strokeLinejoin="round" strokeLinecap="round"
|
|
3311
|
-
aria-hidden="true"
|
|
3312
|
-
>
|
|
3313
|
-
{/* Sheet, cut back at the top-right for the fold. Narrower and one step
|
|
3314
|
-
taller than the folder, sharing its optical band, so the two never
|
|
3315
|
-
look like different-sized icons in one column. */}
|
|
3316
|
-
<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" />
|
|
3317
|
-
{/* The fold itself — the corner turned back on the sheet. */}
|
|
3318
|
-
<path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
|
|
3319
|
-
</svg>
|
|
3320
|
-
)
|
|
3321
|
-
}
|
|
3322
|
-
|
|
3323
3802
|
/** Files shown per expanded directory. The search box is the way to a file in
|
|
3324
3803
|
* a crowded directory; the tree shows enough to browse without flooding the
|
|
3325
3804
|
* list, and says so when it cut the tail. */
|
|
@@ -3397,7 +3876,7 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
|
|
|
3397
3876
|
<label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
|
|
3398
3877
|
<span className={css.funnelChevron} aria-hidden="true" />
|
|
3399
3878
|
<TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
|
|
3400
|
-
<PathFileGlyph />
|
|
3879
|
+
<PathFileGlyph path={`${dir.path}/${file}`} />
|
|
3401
3880
|
<span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
|
|
3402
3881
|
</label>
|
|
3403
3882
|
))}
|
|
@@ -3551,6 +4030,12 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3551
4030
|
return () => { ctrl.abort() }
|
|
3552
4031
|
}, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
|
|
3553
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
|
+
|
|
3554
4039
|
/** Every funnel interaction writes the filter through the box's grammar, so
|
|
3555
4040
|
* the box, the chips and the fetch can never disagree about the query. */
|
|
3556
4041
|
const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
|
|
@@ -3646,7 +4131,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3646
4131
|
spellCheck={false}
|
|
3647
4132
|
/>
|
|
3648
4133
|
</div>
|
|
3649
|
-
{funnelOpen && funnelBox !== null ? createPortal(
|
|
4134
|
+
{funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
|
|
3650
4135
|
<div
|
|
3651
4136
|
ref={funnelPanelRef}
|
|
3652
4137
|
className={css.funnelPop}
|
|
@@ -3808,7 +4293,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3808
4293
|
{hits.map(hit => (
|
|
3809
4294
|
<label key={hit.path} className={css.funnelRow}>
|
|
3810
4295
|
<TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
|
|
3811
|
-
{hit.isFile ? <PathFileGlyph /> : <PathDirGlyph />}
|
|
4296
|
+
{hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
|
|
3812
4297
|
<span className={css.funnelName} title={hit.path}>{hit.path}</span>
|
|
3813
4298
|
</label>
|
|
3814
4299
|
))}
|
|
@@ -3851,7 +4336,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
3851
4336
|
>{t('filterClearAll')}</button>
|
|
3852
4337
|
</div>
|
|
3853
4338
|
</div>,
|
|
3854
|
-
|
|
4339
|
+
funnelHost,
|
|
3855
4340
|
) : null}
|
|
3856
4341
|
{chips.length > 0 ? (
|
|
3857
4342
|
<div className={css.filterChips}>
|
|
@@ -4344,7 +4829,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
|
|
|
4344
4829
|
left-to-right as "what this is, then what happened to it",
|
|
4345
4830
|
and the badge still lands in an aligned column — `.filePath`
|
|
4346
4831
|
is the only flexible child. */}
|
|
4347
|
-
<PathFileGlyph />
|
|
4832
|
+
<PathFileGlyph path={file.path} />
|
|
4348
4833
|
<span className={css.filePath}>{basePart(file.path)}</span>
|
|
4349
4834
|
{file.binary ? <span className={css.fileBinary}>BIN</span> : (
|
|
4350
4835
|
<span className={css.fileCounts}>
|
|
@@ -4374,8 +4859,27 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
|
|
|
4374
4859
|
|
|
4375
4860
|
/* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
|
|
4376
4861
|
|
|
4377
|
-
/**
|
|
4378
|
-
|
|
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 {
|
|
4379
4883
|
const lang = shikiLangOf(path)
|
|
4380
4884
|
const shikiTheme = shikiThemeOf(palette)
|
|
4381
4885
|
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
@@ -4385,10 +4889,57 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
|
|
|
4385
4889
|
() => highlightForRows(rowsWithWords, lang, shikiTheme),
|
|
4386
4890
|
[rowsWithWords, lang, shikiTheme, grammarGen],
|
|
4387
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
|
+
|
|
4388
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}>
|
|
4389
4940
|
<pre className={css.diffPre}>
|
|
4390
4941
|
{rowsWithWords.map((row, i) => (
|
|
4391
|
-
<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}>
|
|
4392
4943
|
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
4393
4944
|
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
4394
4945
|
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
@@ -4398,6 +4949,8 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
|
|
|
4398
4949
|
</div>
|
|
4399
4950
|
))}
|
|
4400
4951
|
</pre>
|
|
4952
|
+
</div>
|
|
4953
|
+
</div>
|
|
4401
4954
|
)
|
|
4402
4955
|
}
|
|
4403
4956
|
|
|
@@ -4423,6 +4976,752 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
|
|
|
4423
4976
|
))
|
|
4424
4977
|
}
|
|
4425
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
|
+
|
|
4426
5725
|
/* ---------- shared helpers ---------- */
|
|
4427
5726
|
|
|
4428
5727
|
/** Split a combined `git diff` into path -> its segment text. */
|