@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
package/src/atomic-json.ts
CHANGED
|
@@ -22,10 +22,9 @@ function delay(ms: number): Promise<void> {
|
|
|
22
22
|
* Write a JSON value so a crash can never leave a truncated file behind.
|
|
23
23
|
*
|
|
24
24
|
* The value is staged into `<path>.tmp` and renamed over the destination, which
|
|
25
|
-
* is atomic within a filesystem.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* rename is retried with backoff before the error is surfaced.
|
|
25
|
+
* is atomic within a filesystem. The rename itself goes through
|
|
26
|
+
* {@link renameWithRetry} — the one home of the Windows EPERM backoff, shared
|
|
27
|
+
* with `write-checked.ts`'s worktree writes rather than duplicated per caller.
|
|
29
28
|
* @param ensureDir - creates the containing directory, recursively.
|
|
30
29
|
* @param writeText - writes a file's whole text.
|
|
31
30
|
* @param rename - renames a path over another.
|
|
@@ -43,9 +42,34 @@ export async function saveJsonAtomic(
|
|
|
43
42
|
await ensureDir(join(path, '..'))
|
|
44
43
|
const tmp = `${path}.tmp`
|
|
45
44
|
await writeText(tmp, `${JSON.stringify(value, null, 2)}\n`)
|
|
45
|
+
await renameWithRetry(rename, delay, tmp, path)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rename `from` over `to`, retrying the short-lived Windows failures.
|
|
50
|
+
*
|
|
51
|
+
* On Windows a rename over an existing destination fails with EPERM while
|
|
52
|
+
* another handle briefly holds it open (a concurrent read, an antivirus scan,
|
|
53
|
+
* the search indexer); those locks are short-lived, so the rename is retried
|
|
54
|
+
* with backoff before the error is surfaced. Extracted from
|
|
55
|
+
* {@link saveJsonAtomic} when the editable diff needed the same atomic write
|
|
56
|
+
* for worktree files: one mechanism, two callers, not two mechanisms that can
|
|
57
|
+
* drift.
|
|
58
|
+
* @param rename - renames a path over another.
|
|
59
|
+
* @param delay - waits the given milliseconds.
|
|
60
|
+
* @param from - the staged temp path.
|
|
61
|
+
* @param to - the destination.
|
|
62
|
+
* @throws whatever `rename` threw, once the retries are exhausted.
|
|
63
|
+
*/
|
|
64
|
+
export async function renameWithRetry(
|
|
65
|
+
rename: (from: string, to: string) => Promise<void>,
|
|
66
|
+
delay: (ms: number) => Promise<void>,
|
|
67
|
+
from: string,
|
|
68
|
+
to: string,
|
|
69
|
+
): Promise<void> {
|
|
46
70
|
for (let attempt = 0; ; attempt++) {
|
|
47
71
|
try {
|
|
48
|
-
await rename(
|
|
72
|
+
await rename(from, to)
|
|
49
73
|
return
|
|
50
74
|
} catch (error) {
|
|
51
75
|
if (attempt >= RENAME_RETRY_DELAYS.length) throw error
|
package/src/blame.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git blame --line-porcelain`, parsed into one record per line.
|
|
3
|
+
*
|
|
4
|
+
* Porcelain is the only blame format worth parsing: the human format packs
|
|
5
|
+
* author, date and code into fixed-width columns that shift with the longest
|
|
6
|
+
* name in the file, and reading it back means guessing where the columns are.
|
|
7
|
+
* The porcelain repeats a full header for every line — verbose on the wire,
|
|
8
|
+
* unambiguous to read.
|
|
9
|
+
*
|
|
10
|
+
* A line nobody has committed yet gets the all-zero sha and git's own English
|
|
11
|
+
* "Not Committed Yet" as its author. That flag is reported separately so the
|
|
12
|
+
* drawer can say it in the reader's language rather than passing git's string
|
|
13
|
+
* through untranslated.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no node, no git, no React. `tests/blame.test.ts` loads it directly.
|
|
16
|
+
*
|
|
17
|
+
* @module @young1lin/dsh-ui-gitworkbench/blame
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One line's provenance. Every field is JSON-safe and always present. */
|
|
21
|
+
export interface BlameLine {
|
|
22
|
+
/** Full commit sha; all zeros for a line not committed yet. */
|
|
23
|
+
readonly hash: string
|
|
24
|
+
readonly author: string
|
|
25
|
+
/** Author time, unix seconds. 0 when git did not say. */
|
|
26
|
+
readonly time: number
|
|
27
|
+
/** The commit's subject line; '' when git did not say. */
|
|
28
|
+
readonly summary: string
|
|
29
|
+
/** Whether this line has no commit behind it yet. */
|
|
30
|
+
readonly uncommitted: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A header line: sha, line in the original, line in the final file, [count]. */
|
|
34
|
+
const ENTRY = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/
|
|
35
|
+
|
|
36
|
+
const ZERO_SHA = '0'.repeat(40)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse blame porcelain into per-line records, indexed by final line number.
|
|
40
|
+
*
|
|
41
|
+
* @param text - stdout of `git blame --line-porcelain -- <path>`.
|
|
42
|
+
* @returns one entry per line of the file, in file order. Gaps cannot happen
|
|
43
|
+
* in well-formed output, but a truncated stream yields a shorter
|
|
44
|
+
* array rather than a hole — the caller renders what it has.
|
|
45
|
+
*/
|
|
46
|
+
export function parseBlame(text: string): BlameLine[] {
|
|
47
|
+
if (text.length === 0) return []
|
|
48
|
+
const byLine = new Map<number, BlameLine>()
|
|
49
|
+
let line = 0
|
|
50
|
+
let hash = ''
|
|
51
|
+
let author = ''
|
|
52
|
+
let time = 0
|
|
53
|
+
let summary = ''
|
|
54
|
+
|
|
55
|
+
for (const raw of text.split('\n')) {
|
|
56
|
+
const head = ENTRY.exec(raw)
|
|
57
|
+
if (head !== null) {
|
|
58
|
+
hash = head[1]!
|
|
59
|
+
line = Number.parseInt(head[3]!, 10)
|
|
60
|
+
// Each entry restates its own fields; carrying the previous line's over
|
|
61
|
+
// would attribute a line to whatever came before it in the stream.
|
|
62
|
+
author = ''
|
|
63
|
+
time = 0
|
|
64
|
+
summary = ''
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (raw.startsWith('author ')) { author = raw.slice(7); continue }
|
|
68
|
+
if (raw.startsWith('author-time ')) {
|
|
69
|
+
const parsed = Number.parseInt(raw.slice(12), 10)
|
|
70
|
+
time = Number.isFinite(parsed) ? parsed : 0
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (raw.startsWith('summary ')) { summary = raw.slice(8); continue }
|
|
74
|
+
// The content line, which closes the entry. Its text is the file's own and
|
|
75
|
+
// the drawer already has it, so only the provenance is kept.
|
|
76
|
+
if (raw.startsWith('\t') && line > 0) {
|
|
77
|
+
byLine.set(line, {
|
|
78
|
+
hash,
|
|
79
|
+
author,
|
|
80
|
+
time,
|
|
81
|
+
summary,
|
|
82
|
+
uncommitted: hash === ZERO_SHA,
|
|
83
|
+
})
|
|
84
|
+
line = 0
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const highest = byLine.size === 0 ? 0 : Math.max(...byLine.keys())
|
|
89
|
+
const out: BlameLine[] = []
|
|
90
|
+
for (let at = 1; at <= highest; at += 1) {
|
|
91
|
+
out.push(byLine.get(at) ?? { hash: '', author: '', time: 0, summary: '', uncommitted: false })
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side pane's editor: a real CodeMirror 6 view, wired to the pane's
|
|
3
|
+
* existing buffer state.
|
|
4
|
+
*
|
|
5
|
+
* What this replaces was a transparent textarea laid over its own rendered
|
|
6
|
+
* lines. That kept the caret on the glyphs, but a textarea is a textarea: Tab
|
|
7
|
+
* left the field, there was no undo stack of its own, no multiple selections,
|
|
8
|
+
* no find-in-file. Those are not polish — they are what "editing" means to
|
|
9
|
+
* anyone who has used an editor, and the pane was asking people to edit.
|
|
10
|
+
*
|
|
11
|
+
* CodeMirror is here for the EDITING only. Highlighting still comes from
|
|
12
|
+
* shiki, through {@link tokenRanges}: the diff columns beside this editor are
|
|
13
|
+
* painted by shiki, and a second grammar engine would cost another megabyte of
|
|
14
|
+
* bundle to render the same file in slightly different colours. So the tokens
|
|
15
|
+
* the pane already computed are handed over as decorations.
|
|
16
|
+
*
|
|
17
|
+
* The document is CONTROLLED by the pane, not owned here: `value` is the
|
|
18
|
+
* pane's buffer, and every change is reported back through `onChange`. The
|
|
19
|
+
* effect below writes an incoming `value` into the view only when it actually
|
|
20
|
+
* differs from what the view holds, so a save's round trip does not fight the
|
|
21
|
+
* caret.
|
|
22
|
+
*
|
|
23
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/CodeEditor
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
27
|
+
import { EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'
|
|
28
|
+
import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, type DecorationSet } from '@codemirror/view'
|
|
29
|
+
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
|
30
|
+
import { indentUnit } from '@codemirror/language'
|
|
31
|
+
import { search, searchKeymap } from '@codemirror/search'
|
|
32
|
+
|
|
33
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
34
|
+
import { blameCompartment, blameField, blameGutter, setBlame } from './blame-gutter.ts'
|
|
35
|
+
import { bufferDiff } from './cm-diff.ts'
|
|
36
|
+
import { tokenRanges } from './cm-tokens.ts'
|
|
37
|
+
import type { HighlightRun } from './highlight.ts'
|
|
38
|
+
import type { BlameLine } from './GitWorkbenchPanel.tsx'
|
|
39
|
+
|
|
40
|
+
/** Carries a fresh set of shiki-derived decorations into the view. */
|
|
41
|
+
const setPaint = StateEffect.define<DecorationSet>()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The decoration layer. It maps through document changes so the colours stay
|
|
45
|
+
* on their text between repaints — without that, a keystroke would smear every
|
|
46
|
+
* token after the caret until the next highlight pass landed.
|
|
47
|
+
*/
|
|
48
|
+
const paintField = StateField.define<DecorationSet>({
|
|
49
|
+
create: () => Decoration.none,
|
|
50
|
+
update(paint, tr) {
|
|
51
|
+
for (const effect of tr.effects) {
|
|
52
|
+
if (effect.is(setPaint)) return effect.value
|
|
53
|
+
}
|
|
54
|
+
return tr.docChanged ? paint.map(tr.changes) : paint
|
|
55
|
+
},
|
|
56
|
+
provide: field => EditorView.decorations.from(field),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** One span's inline style, from the shiki run that produced it. */
|
|
60
|
+
function styleFor(color: string | undefined, italic: boolean | undefined): string {
|
|
61
|
+
const paint = color === undefined ? '' : 'color:' + color + ';'
|
|
62
|
+
return italic === true ? paint + 'font-style:italic;' : paint
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build the decoration set for one buffer's worth of shiki runs. */
|
|
66
|
+
function paintFor(text: string, syntax: readonly (readonly HighlightRun[])[] | undefined): DecorationSet {
|
|
67
|
+
const ranges = tokenRanges(text.split('\n'), syntax)
|
|
68
|
+
return Decoration.set(
|
|
69
|
+
ranges.map(range => Decoration
|
|
70
|
+
.mark({ attributes: { style: styleFor(range.color, range.italic) } })
|
|
71
|
+
.range(range.from, range.to)),
|
|
72
|
+
true,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The other side's text, kept in state so the diff layer can recompute from
|
|
77
|
+
* a transaction alone rather than from a closure over some past render. */
|
|
78
|
+
const setOriginal = StateEffect.define<string>()
|
|
79
|
+
const originalText = StateField.define<string>({
|
|
80
|
+
create: () => '',
|
|
81
|
+
update(held, tr) {
|
|
82
|
+
for (const effect of tr.effects) {
|
|
83
|
+
if (effect.is(setOriginal)) return effect.value
|
|
84
|
+
}
|
|
85
|
+
return held
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The add/delete tint, recomputed on every keystroke.
|
|
91
|
+
*
|
|
92
|
+
* Arming the editor used to take the diff colours away, because the pane's
|
|
93
|
+
* tints come from git's diff and git has not seen a keystroke. Watching the
|
|
94
|
+
* change take shape is the reason to edit inside a diff view at all, so the
|
|
95
|
+
* tint is recomputed here from the text on both sides instead.
|
|
96
|
+
*
|
|
97
|
+
* This diff is a READING AID. No git operation uses it: the block actions
|
|
98
|
+
* still send line indices against the host's own `diffSha`-stamped patch.
|
|
99
|
+
*/
|
|
100
|
+
const diffField = StateField.define<DecorationSet>({
|
|
101
|
+
create: state => diffDecorations(state),
|
|
102
|
+
update(deco, tr) {
|
|
103
|
+
const reset = tr.effects.some(effect => effect.is(setOriginal))
|
|
104
|
+
return tr.docChanged || reset ? diffDecorations(tr.state) : deco
|
|
105
|
+
},
|
|
106
|
+
provide: field => EditorView.decorations.from(field),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
function diffDecorations(state: EditorState): DecorationSet {
|
|
110
|
+
const original = state.field(originalText, false) ?? ''
|
|
111
|
+
const doc = state.doc.toString()
|
|
112
|
+
const { changed, deletedBefore } = bufferDiff(original, doc)
|
|
113
|
+
const marks: { at: number; deco: Decoration }[] = []
|
|
114
|
+
const lineCount = state.doc.lines
|
|
115
|
+
for (const line of changed) {
|
|
116
|
+
if (line <= lineCount) marks.push({ at: state.doc.line(line).from, deco: CHANGED_LINE })
|
|
117
|
+
}
|
|
118
|
+
for (const line of deletedBefore) {
|
|
119
|
+
if (line <= lineCount) marks.push({ at: state.doc.line(line).from, deco: DELETED_AT })
|
|
120
|
+
}
|
|
121
|
+
marks.sort((a, b) => a.at - b.at)
|
|
122
|
+
return Decoration.set(marks.map(mark => mark.deco.range(mark.at)), true)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const CHANGED_LINE = Decoration.line({ class: 'cm-gwChanged' })
|
|
126
|
+
const DELETED_AT = Decoration.line({ class: 'cm-gwDeleted' })
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Metrics restated to match the diff columns beside this editor exactly: same
|
|
130
|
+
* family, same size, same 20px rhythm, ligatures off. The pane's own CSS
|
|
131
|
+
* variables carry the colours, so the editor follows the drawer's palette
|
|
132
|
+
* without a CodeMirror theme per palette.
|
|
133
|
+
*/
|
|
134
|
+
const paneTheme = EditorView.theme({
|
|
135
|
+
'&': { backgroundColor: 'transparent', color: 'var(--gs-fg)', height: 'auto' },
|
|
136
|
+
'&.cm-focused': { outline: 'none' },
|
|
137
|
+
'.cm-scroller': {
|
|
138
|
+
overflow: 'visible',
|
|
139
|
+
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
|
|
140
|
+
fontSize: 'var(--gs-t-dense)',
|
|
141
|
+
lineHeight: '20px',
|
|
142
|
+
fontVariantLigatures: 'none',
|
|
143
|
+
},
|
|
144
|
+
'.cm-content': { padding: '0', caretColor: 'var(--gs-accent)' },
|
|
145
|
+
'.cm-gutters': {
|
|
146
|
+
backgroundColor: 'transparent',
|
|
147
|
+
color: 'var(--gs-fg-faint)',
|
|
148
|
+
border: 'none',
|
|
149
|
+
paddingRight: '8px',
|
|
150
|
+
},
|
|
151
|
+
'.cm-activeLine': { backgroundColor: 'var(--gs-hover)' },
|
|
152
|
+
// Same tints the diff columns use, so a line the reader just typed reads as
|
|
153
|
+
// the same kind of thing as a line git already knows about.
|
|
154
|
+
'.cm-gwChanged': { backgroundColor: 'var(--gs-add-line)' },
|
|
155
|
+
'.cm-gwDeleted': { boxShadow: 'inset 0 2px 0 0 var(--gs-del-line)' },
|
|
156
|
+
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--gs-fg-dim)' },
|
|
157
|
+
'.cm-cursor': { borderLeftColor: 'var(--gs-accent)' },
|
|
158
|
+
// The blame gutter. Dim and monospaced-narrow: it sits beside code the
|
|
159
|
+
// reader came to read, so it must be legible without competing.
|
|
160
|
+
'.cm-gwBlameGutter': {
|
|
161
|
+
color: 'var(--gs-fg-faint)',
|
|
162
|
+
fontSize: 'var(--gs-t-meta)',
|
|
163
|
+
paddingRight: '10px',
|
|
164
|
+
borderRight: '1px solid var(--gs-border)',
|
|
165
|
+
marginRight: '6px',
|
|
166
|
+
},
|
|
167
|
+
'.cm-gwBlameCell': {
|
|
168
|
+
display: 'block',
|
|
169
|
+
maxWidth: '12ch',
|
|
170
|
+
overflow: 'hidden',
|
|
171
|
+
textOverflow: 'ellipsis',
|
|
172
|
+
whiteSpace: 'nowrap',
|
|
173
|
+
cursor: 'pointer',
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
export function CodeEditor({ value, original, onChange, syntax, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
|
|
178
|
+
/** The pane's buffer. The view is written to only when this really differs. */
|
|
179
|
+
value: string
|
|
180
|
+
/** The other side's whole text — the index side, for the unstaged layer this
|
|
181
|
+
* editor lives on. What the live tint is computed against. */
|
|
182
|
+
original: string
|
|
183
|
+
onChange: (next: string) => void
|
|
184
|
+
/** `highlightFile`'s runs for this buffer; undefined while a grammar loads. */
|
|
185
|
+
syntax: readonly (readonly HighlightRun[])[] | undefined
|
|
186
|
+
/** One indent level, from `detectIndent` — what Tab inserts. */
|
|
187
|
+
indent: string
|
|
188
|
+
ariaLabel: string
|
|
189
|
+
/** Ctrl/Cmd+S. Bound inside the view too: CodeMirror sees the key first, and
|
|
190
|
+
* a save shortcut that works everywhere except inside the editor is worse
|
|
191
|
+
* than none at all. */
|
|
192
|
+
onSave: () => void
|
|
193
|
+
/** Per-line provenance for the blame gutter, or null for "not showing".
|
|
194
|
+
* The gutter is added and removed with it, because an installed gutter with
|
|
195
|
+
* no markers still reserves its column. */
|
|
196
|
+
blame?: readonly BlameLine[] | null
|
|
197
|
+
/** The drawer's own wording for a line no commit covers yet. */
|
|
198
|
+
notCommitted?: string
|
|
199
|
+
/** A file the editor may show but must not change — a CRLF or non-UTF-8
|
|
200
|
+
* file, where any save would rewrite bytes nobody touched. */
|
|
201
|
+
readOnly?: boolean
|
|
202
|
+
/** A click in the blame gutter, with the 1-based line number. */
|
|
203
|
+
onBlameClick?: (line: number) => void
|
|
204
|
+
}): ReactNode {
|
|
205
|
+
/**
|
|
206
|
+
* Editable, and SAID to be editable, from one boolean.
|
|
207
|
+
*
|
|
208
|
+
* Nothing about rendered code looks like a field. In the Files tab this
|
|
209
|
+
* editor is live the moment a file opens — no button in between — so
|
|
210
|
+
* without a mark the reader learns they may type by typing, and learns
|
|
211
|
+
* they may NOT by typing into a pane that swallows it. The stylesheet
|
|
212
|
+
* draws a rule down the leading edge for this attribute and lights it
|
|
213
|
+
* while the caret is inside.
|
|
214
|
+
*
|
|
215
|
+
* Derived here rather than taken as a second prop so the mark and
|
|
216
|
+
* `EditorState.readOnly` below cannot disagree: a pane that shows the
|
|
217
|
+
* rule and then refuses the keystroke is worse than one with no rule.
|
|
218
|
+
*/
|
|
219
|
+
const editable = readOnly !== true
|
|
220
|
+
const host = useRef<HTMLDivElement>(null)
|
|
221
|
+
const view = useRef<EditorView | null>(null)
|
|
222
|
+
// Read inside CodeMirror's own callbacks, which close over the render that
|
|
223
|
+
// created the view — several states old by the time a key is pressed.
|
|
224
|
+
const latest = useRef({ onChange, onSave, onBlameClick })
|
|
225
|
+
latest.current = { onChange, onSave, onBlameClick }
|
|
226
|
+
// Stable across renders so reconfiguring the gutter does not depend on a
|
|
227
|
+
// callback identity that changes every time the pane re-renders.
|
|
228
|
+
const pick = useRef((line: number) => { latest.current.onBlameClick?.(line) }).current
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const parent = host.current
|
|
232
|
+
if (parent === null) return
|
|
233
|
+
const extensions: Extension[] = [
|
|
234
|
+
lineNumbers(),
|
|
235
|
+
history(),
|
|
236
|
+
search({ top: true }),
|
|
237
|
+
highlightActiveLine(),
|
|
238
|
+
paintField,
|
|
239
|
+
blameField,
|
|
240
|
+
blameCompartment.of([]),
|
|
241
|
+
EditorState.readOnly.of(!editable),
|
|
242
|
+
originalText.init(() => original),
|
|
243
|
+
diffField,
|
|
244
|
+
paneTheme,
|
|
245
|
+
keymap.of([
|
|
246
|
+
{ key: 'Mod-s', preventDefault: true, run: () => { latest.current.onSave(); return true } },
|
|
247
|
+
...searchKeymap,
|
|
248
|
+
...historyKeymap,
|
|
249
|
+
indentWithTab,
|
|
250
|
+
...defaultKeymap,
|
|
251
|
+
]),
|
|
252
|
+
EditorView.updateListener.of(update => {
|
|
253
|
+
if (update.docChanged) latest.current.onChange(update.state.doc.toString())
|
|
254
|
+
}),
|
|
255
|
+
EditorState.allowMultipleSelections.of(true),
|
|
256
|
+
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
|
|
257
|
+
]
|
|
258
|
+
const created = new EditorView({ state: EditorState.create({ doc: value, extensions }), parent })
|
|
259
|
+
view.current = created
|
|
260
|
+
// Arming dropped the caret into the buffer for the textarea too: the click
|
|
261
|
+
// that armed the editor said "I want to type here".
|
|
262
|
+
created.focus()
|
|
263
|
+
return () => { created.destroy(); view.current = null }
|
|
264
|
+
// Built once per armed session. `value` and `syntax` flow in through the
|
|
265
|
+
// effects below; rebuilding the view on either would drop the caret, the
|
|
266
|
+
// undo stack and the selection on every keystroke.
|
|
267
|
+
}, [])
|
|
268
|
+
|
|
269
|
+
// Blame arriving, changing, or being switched off. The gutter itself goes
|
|
270
|
+
// in and out through the compartment; the field carries the lines.
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const current = view.current
|
|
273
|
+
if (current === null) return
|
|
274
|
+
const lines = blame ?? null
|
|
275
|
+
current.dispatch({
|
|
276
|
+
effects: [
|
|
277
|
+
blameCompartment.reconfigure(lines === null ? [] : blameGutter(notCommitted ?? '', pick)),
|
|
278
|
+
setBlame.of(lines),
|
|
279
|
+
],
|
|
280
|
+
})
|
|
281
|
+
}, [blame, notCommitted])
|
|
282
|
+
|
|
283
|
+
// The other side, when a refresh brings a new one in.
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
const current = view.current
|
|
286
|
+
if (current === null) return
|
|
287
|
+
if (current.state.field(originalText, false) === original) return
|
|
288
|
+
current.dispatch({ effects: setOriginal.of(original) })
|
|
289
|
+
}, [original])
|
|
290
|
+
|
|
291
|
+
// The indent unit can change when the pane moves to another file.
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
const current = view.current
|
|
294
|
+
if (current === null) return
|
|
295
|
+
current.dispatch({ effects: StateEffect.appendConfig.of(indentUnit.of(indent)) })
|
|
296
|
+
}, [indent])
|
|
297
|
+
|
|
298
|
+
// The pane's buffer, written in only when it really differs — a save's round
|
|
299
|
+
// trip hands back the same text, and rewriting it would move the caret.
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
const current = view.current
|
|
302
|
+
if (current === null) return
|
|
303
|
+
const held = current.state.doc.toString()
|
|
304
|
+
if (held === value) return
|
|
305
|
+
current.dispatch({ changes: { from: 0, to: held.length, insert: value } })
|
|
306
|
+
}, [value])
|
|
307
|
+
|
|
308
|
+
// Repaint. Depends on `value` as well as `syntax` so it runs after the write
|
|
309
|
+
// above, against the text the view now actually holds.
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
const current = view.current
|
|
312
|
+
if (current === null) return
|
|
313
|
+
current.dispatch({ effects: setPaint.of(paintFor(current.state.doc.toString(), syntax)) })
|
|
314
|
+
}, [syntax, value])
|
|
315
|
+
|
|
316
|
+
return <div ref={host} className={css.cmHost} data-editable={editable ? '' : undefined} />
|
|
317
|
+
}
|