@young1lin/dsh-ui-gitworkbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick state for the commit tree.
|
|
3
|
+
*
|
|
4
|
+
* IDEA presents a commit as a set of ticks rather than as a staging area, and
|
|
5
|
+
* the drawer follows it: a tick IS `git add`, applied immediately, so what the
|
|
6
|
+
* box shows and what `git status` reports can never drift apart. Nothing here
|
|
7
|
+
* decides that policy — this module only answers what a tick looks like and
|
|
8
|
+
* what clicking one should do, which is the part with rules worth testing.
|
|
9
|
+
*
|
|
10
|
+
* The one thing IDEA's model cannot say, and this one can, is a file that is
|
|
11
|
+
* staged AND edited again: part of it would go into the commit and part would
|
|
12
|
+
* not. That is not "ticked", and calling it ticked would make the box lie about
|
|
13
|
+
* what it is about to commit. It is the same indeterminate state a directory
|
|
14
|
+
* has when only some of its files are ticked, and it resolves the same way.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CheckState = 'on' | 'off' | 'partial'
|
|
18
|
+
|
|
19
|
+
/** The index/worktree pair from git's porcelain XY columns. */
|
|
20
|
+
export interface StageFlags {
|
|
21
|
+
readonly staged?: boolean
|
|
22
|
+
readonly unstaged?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One file's tick.
|
|
27
|
+
* @param file - the file's stage flags; both may be true at once.
|
|
28
|
+
*/
|
|
29
|
+
export function fileCheckState(file: StageFlags): CheckState {
|
|
30
|
+
if (file.staged !== true) return 'off'
|
|
31
|
+
return file.unstaged === true ? 'partial' : 'on'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Roll descendant ticks up to the directory that holds them.
|
|
36
|
+
*
|
|
37
|
+
* Mixed children are indeterminate however lopsided the mix: one ticked file
|
|
38
|
+
* among two hundred is still "some of this directory", and rounding it to off
|
|
39
|
+
* would hide a file from the commit set the user is looking at.
|
|
40
|
+
* @param states - every descendant's tick, files and subdirectories alike.
|
|
41
|
+
*/
|
|
42
|
+
export function rollUp(states: Iterable<CheckState>): CheckState {
|
|
43
|
+
let seenOn = false
|
|
44
|
+
let seenOff = false
|
|
45
|
+
for (const state of states) {
|
|
46
|
+
if (state === 'partial') return 'partial'
|
|
47
|
+
if (state === 'on') seenOn = true
|
|
48
|
+
else seenOff = true
|
|
49
|
+
if (seenOn && seenOff) return 'partial'
|
|
50
|
+
}
|
|
51
|
+
return seenOn ? 'on' : 'off'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a tick does to the index. */
|
|
55
|
+
export type TickAction = 'stage' | 'unstage'
|
|
56
|
+
|
|
57
|
+
/** One queued click: a path, and what the click asked to do with it. */
|
|
58
|
+
export interface Tick {
|
|
59
|
+
readonly path: string
|
|
60
|
+
readonly action: TickAction
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What clicking a tick in this state means.
|
|
65
|
+
*
|
|
66
|
+
* Indeterminate stages rather than unstages: a half-ticked box invites you to
|
|
67
|
+
* finish it, and the destructive reading (throw away what is already staged)
|
|
68
|
+
* is not what a click on a checkbox should ever do.
|
|
69
|
+
* @param state - the tick's current state.
|
|
70
|
+
*/
|
|
71
|
+
export function nextAction(state: CheckState): TickAction {
|
|
72
|
+
return state === 'on' ? 'unstage' : 'stage'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The paths an action actually has to touch.
|
|
77
|
+
*
|
|
78
|
+
* Staging skips what is already fully staged and unstaging skips what was never
|
|
79
|
+
* staged, so ticking a directory of two hundred files sends git only the ones
|
|
80
|
+
* that change — and an action with nothing to do sends nothing at all.
|
|
81
|
+
* @param files - the files under the tick that was clicked.
|
|
82
|
+
* @param action - from {@link nextAction}.
|
|
83
|
+
*/
|
|
84
|
+
export function pathsFor<T extends StageFlags & { readonly path: string }>(
|
|
85
|
+
files: readonly T[],
|
|
86
|
+
action: TickAction,
|
|
87
|
+
): string[] {
|
|
88
|
+
return files
|
|
89
|
+
.filter(file => action === 'stage' ? file.unstaged === true : file.staged === true)
|
|
90
|
+
.map(file => file.path)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The stage flags a tick leaves behind the moment it is clicked, rather than
|
|
95
|
+
* the moment git confirms it.
|
|
96
|
+
* @param action - from {@link nextAction}.
|
|
97
|
+
*/
|
|
98
|
+
export function tickedFlags(action: TickAction): { staged: boolean; unstaged: boolean } {
|
|
99
|
+
return action === 'stage' ? { staged: true, unstaged: false } : { staged: false, unstaged: true }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The fetched files with the ticks still awaiting git laid over them.
|
|
104
|
+
*
|
|
105
|
+
* The overlay is what makes a tick feel instant: it answers "what did I just
|
|
106
|
+
* click" instead of waiting for the refetch to answer it. Entries the payload
|
|
107
|
+
* does not list are left alone — the payload is the authority on what exists,
|
|
108
|
+
* and a tick for a path it does not carry is settled, not painted onto nothing.
|
|
109
|
+
*
|
|
110
|
+
* Returns the very same array when the overlay changes nothing: the tree
|
|
111
|
+
* re-renders on this value, and a fresh array every poll would throw its
|
|
112
|
+
* memoisation away.
|
|
113
|
+
* @param files - the files the newest payload reported.
|
|
114
|
+
* @param pending - ticks clicked since the payload last confirmed one.
|
|
115
|
+
*/
|
|
116
|
+
export function withPendingTicks<T extends StageFlags & { readonly path: string }>(
|
|
117
|
+
files: readonly T[],
|
|
118
|
+
pending: ReadonlyMap<string, TickAction>,
|
|
119
|
+
): readonly T[] {
|
|
120
|
+
if (pending.size === 0) return files
|
|
121
|
+
let touched = false
|
|
122
|
+
const shown = files.map(file => {
|
|
123
|
+
const action = pending.get(file.path)
|
|
124
|
+
if (action === undefined) return file
|
|
125
|
+
touched = true
|
|
126
|
+
return { ...file, ...tickedFlags(action) }
|
|
127
|
+
})
|
|
128
|
+
return touched ? shown : files
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Which pending ticks the newest payload has confirmed, and which may be
|
|
133
|
+
* dropped from the overlay.
|
|
134
|
+
*
|
|
135
|
+
* A stage is settled once the payload shows the file staged — a newer edit on
|
|
136
|
+
* top does not undo the add, and demanding full-flag agreement would freeze
|
|
137
|
+
* the overlay over a file the agent is still editing, hiding the very edit the
|
|
138
|
+
* payload came to report. An unstage settles once the index no longer holds
|
|
139
|
+
* the file. A path the payload no longer lists at all was committed or
|
|
140
|
+
* reverted elsewhere, and counts as settled too, or its tick would stick
|
|
141
|
+
* forever.
|
|
142
|
+
* @param files - the files the newest payload reported.
|
|
143
|
+
* @param pending - ticks clicked since the payload last confirmed one.
|
|
144
|
+
*/
|
|
145
|
+
export function settledTicks<T extends StageFlags & { readonly path: string }>(
|
|
146
|
+
files: readonly T[],
|
|
147
|
+
pending: ReadonlyMap<string, TickAction>,
|
|
148
|
+
): ReadonlyMap<string, TickAction> {
|
|
149
|
+
const flagsByPath = new Map(files.map(file => [file.path, file]))
|
|
150
|
+
const settled = new Map<string, TickAction>()
|
|
151
|
+
for (const [path, action] of pending) {
|
|
152
|
+
const file = flagsByPath.get(path)
|
|
153
|
+
const staged = file?.staged === true
|
|
154
|
+
if (file === undefined || (action === 'stage' ? staged : !staged)) settled.set(path, action)
|
|
155
|
+
}
|
|
156
|
+
return settled
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The next git call the queue owes: every tick that shares the first entry's
|
|
161
|
+
* action, gathered however late it arrived.
|
|
162
|
+
*
|
|
163
|
+
* One action per call, always — `git add` and `git restore --staged` in one
|
|
164
|
+
* invocation is not a command git accepts. Batched by action rather than by
|
|
165
|
+
* contiguity, so a tick that arrived between two of the same kind does not
|
|
166
|
+
* split them into two spawns.
|
|
167
|
+
* @param queue - clicked ticks not yet handed to git.
|
|
168
|
+
* @returns the batch to run, or null when the queue is empty.
|
|
169
|
+
*/
|
|
170
|
+
export function nextBatch(queue: readonly Tick[]): { action: TickAction; paths: string[] } | null {
|
|
171
|
+
const first = queue[0]
|
|
172
|
+
if (first === undefined) return null
|
|
173
|
+
const paths: string[] = []
|
|
174
|
+
for (const tick of queue) {
|
|
175
|
+
if (tick.action === first.action) paths.push(tick.path)
|
|
176
|
+
}
|
|
177
|
+
return { action: first.action, paths }
|
|
178
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawer's theme model: which palettes exist, which one a given appearance
|
|
3
|
+
* setting resolves to, and how the two styling scopes combine.
|
|
4
|
+
*
|
|
5
|
+
* Kept apart from the panel so it carries no CSS-module or React import, which
|
|
6
|
+
* is what lets `tests/theme-palettes.test.ts` hold the families here and the
|
|
7
|
+
* palettes in `GitWorkbenchPanel.module.css` to each other. Every family named here
|
|
8
|
+
* MUST have both an `-dark` and an `-light` palette in that stylesheet: a
|
|
9
|
+
* missing one leaves the drawer with no `--gs-*` token defined at all, which
|
|
10
|
+
* renders as unstyled black-on-white rather than as a visible error.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Colour mode. `system` follows the host app (dsh writes `body[data-ds-dark-theme]`
|
|
15
|
+
* when its resolved palette is dark); `light` / `dark` pin the drawer even if
|
|
16
|
+
* dsh itself is the other scheme.
|
|
17
|
+
*/
|
|
18
|
+
export type ColorMode = 'system' | 'light' | 'dark'
|
|
19
|
+
|
|
20
|
+
/** Theme family. Each supplies a light and a dark palette. */
|
|
21
|
+
export type ThemeFamily = 'github' | 'idea' | 'vscode' | 'one' | 'solarized' | 'nord' | 'cyberpunk'
|
|
22
|
+
|
|
23
|
+
export const COLOR_MODES: readonly ColorMode[] = ['system', 'light', 'dark']
|
|
24
|
+
|
|
25
|
+
/** One family's menu entry. */
|
|
26
|
+
export interface ThemeFamilyOption {
|
|
27
|
+
readonly id: ThemeFamily
|
|
28
|
+
readonly label: string
|
|
29
|
+
/** Ground, accent, and add colour — the three the swatch previews. */
|
|
30
|
+
readonly swatch: readonly [string, string, string]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Families in menu order, each with the colours that preview it. */
|
|
34
|
+
export const THEME_FAMILIES: readonly ThemeFamilyOption[] = [
|
|
35
|
+
{ id: 'github', label: 'GitHub', swatch: ['#0d1117', '#58a6ff', '#3fb950'] },
|
|
36
|
+
{ id: 'idea', label: 'IntelliJ IDEA', swatch: ['#1e1f22', '#3574f0', '#5fad65'] },
|
|
37
|
+
{ id: 'vscode', label: 'VS Code', swatch: ['#1e1e1e', '#0098ff', '#89d185'] },
|
|
38
|
+
{ id: 'one', label: 'One', swatch: ['#282c34', '#61afef', '#98c379'] },
|
|
39
|
+
{ id: 'solarized', label: 'Solarized', swatch: ['#002b36', '#268bd2', '#859900'] },
|
|
40
|
+
{ id: 'nord', label: 'Nord', swatch: ['#2e3440', '#88c0d0', '#a3be8c'] },
|
|
41
|
+
{ id: 'cyberpunk', label: 'Cyberpunk', swatch: ['#0b0417', '#00f0ff', '#ff2e88'] },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Persisted appearance choice. */
|
|
45
|
+
export interface Appearance {
|
|
46
|
+
readonly mode: ColorMode
|
|
47
|
+
readonly family: ThemeFamily
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Follow the host app, in the default palette. */
|
|
51
|
+
export const DEFAULT_APPEARANCE: Appearance = { mode: 'system', family: 'github' }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Narrow a stored value to an appearance choice.
|
|
55
|
+
* @param value - value read back from storage, written by any earlier build.
|
|
56
|
+
* @returns whether it names a mode and family this build still has.
|
|
57
|
+
*/
|
|
58
|
+
export function isAppearance(value: unknown): value is Appearance {
|
|
59
|
+
if (typeof value !== 'object' || value === null) return false
|
|
60
|
+
const { mode, family } = value as Partial<Appearance>
|
|
61
|
+
return COLOR_MODES.some(known => known === mode)
|
|
62
|
+
&& THEME_FAMILIES.some(known => known.id === family)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Body attribute dsh's ThemePresenter toggles for the active palette.
|
|
67
|
+
* This plugin reads it rather than `prefers-color-scheme`: a plugin follows
|
|
68
|
+
* the host, not the computer.
|
|
69
|
+
*/
|
|
70
|
+
export const DSH_DARK_ATTR = 'data-ds-dark-theme'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether dsh's resolved palette is currently dark.
|
|
74
|
+
* @param body - `document.body`, or a stand-in in tests.
|
|
75
|
+
*/
|
|
76
|
+
export function hostSchemeDark(body: { hasAttribute(name: string): boolean }): boolean {
|
|
77
|
+
return body.hasAttribute(DSH_DARK_ATTR)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the palette to paint with.
|
|
82
|
+
* @param appearance - the user's stored choice.
|
|
83
|
+
* @param hostDark - whether dsh's resolved palette is currently dark.
|
|
84
|
+
* @returns the `data-gs-theme` value naming one palette.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveTheme(appearance: Appearance, hostDark: boolean): string {
|
|
87
|
+
const dark = appearance.mode === 'system' ? hostDark : appearance.mode === 'dark'
|
|
88
|
+
return `${appearance.family}-${dark ? 'dark' : 'light'}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------------------- custom styling ---------------------------- */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One scope's styling, mirroring the host's `StyleEntry`.
|
|
95
|
+
*
|
|
96
|
+
* The client never has to validate these: the host sanitizes on both read and
|
|
97
|
+
* write, so a value that reaches here is already in range and the image is
|
|
98
|
+
* already known to be a base64 `data:` URL safe to interpolate into `url()`.
|
|
99
|
+
*/
|
|
100
|
+
export interface StyleEntry {
|
|
101
|
+
readonly css: string
|
|
102
|
+
readonly image: string
|
|
103
|
+
readonly blur: number
|
|
104
|
+
readonly veil: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Which scope an edit applies to. */
|
|
108
|
+
export type StyleScope = 'project' | 'global'
|
|
109
|
+
|
|
110
|
+
export const STYLE_SCOPES: readonly StyleScope[] = ['project', 'global']
|
|
111
|
+
|
|
112
|
+
/** What the host reports for a directory: each scope, unresolved. */
|
|
113
|
+
export interface StyleSettings {
|
|
114
|
+
readonly project: StyleEntry | null
|
|
115
|
+
readonly global: StyleEntry | null
|
|
116
|
+
/** Repository root the project scope is keyed by; null outside a repository. */
|
|
117
|
+
readonly repoRoot: string | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An entry with nothing set — what an unconfigured scope opens on. */
|
|
121
|
+
export const DEFAULT_STYLE: StyleEntry = { css: '', image: '', blur: 18, veil: 78 }
|
|
122
|
+
|
|
123
|
+
/** Top of the blur slider. Must match the host's own cap, which clamps to it. */
|
|
124
|
+
export const STYLE_BLUR_MAX = 60
|
|
125
|
+
|
|
126
|
+
/** Nothing configured in either scope. */
|
|
127
|
+
export const EMPTY_SETTINGS: StyleSettings = { project: null, global: null, repoRoot: null }
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The background image actually shown.
|
|
131
|
+
*
|
|
132
|
+
* An image is not composable, so the project's replaces the global one outright
|
|
133
|
+
* rather than merging field by field — the blur and veil that were tuned for one
|
|
134
|
+
* photograph say nothing about another.
|
|
135
|
+
* @param settings - both scopes.
|
|
136
|
+
* @returns the winning entry's background, or null when neither sets an image.
|
|
137
|
+
*/
|
|
138
|
+
export function effectiveBackground(settings: StyleSettings): StyleEntry | null {
|
|
139
|
+
for (const entry of [settings.project, settings.global]) {
|
|
140
|
+
if (entry !== null && entry.image.length > 0) return entry
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The custom CSS actually applied.
|
|
147
|
+
*
|
|
148
|
+
* Both scopes apply, global first, so the project's rules win ties by cascade
|
|
149
|
+
* order while a global rule the project does not mention still holds. That is
|
|
150
|
+
* what CSS already does with two stylesheets, and it is more useful than an
|
|
151
|
+
* override: a global rule can set the type scale for every project while one
|
|
152
|
+
* project recolours its accent.
|
|
153
|
+
* @param settings - both scopes.
|
|
154
|
+
* @returns the concatenated stylesheet, empty when neither scope sets any.
|
|
155
|
+
*/
|
|
156
|
+
export function effectiveCss(settings: StyleSettings): string {
|
|
157
|
+
const parts: string[] = []
|
|
158
|
+
for (const entry of [settings.global, settings.project]) {
|
|
159
|
+
if (entry !== null && entry.css.trim().length > 0) parts.push(entry.css)
|
|
160
|
+
}
|
|
161
|
+
return parts.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param settings - both scopes.
|
|
166
|
+
* @param scope - the scope being edited.
|
|
167
|
+
* @returns that scope's entry, or the defaults when it has none yet.
|
|
168
|
+
*/
|
|
169
|
+
export function entryFor(settings: StyleSettings, scope: StyleScope): StyleEntry {
|
|
170
|
+
return (scope === 'project' ? settings.project : settings.global) ?? DEFAULT_STYLE
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param settings - both scopes.
|
|
175
|
+
* @param scope - the scope that changed.
|
|
176
|
+
* @param entry - its new value.
|
|
177
|
+
* @returns settings with that scope replaced.
|
|
178
|
+
*/
|
|
179
|
+
export function withScope(settings: StyleSettings, scope: StyleScope, entry: StyleEntry): StyleSettings {
|
|
180
|
+
return scope === 'project' ? { ...settings, project: entry } : { ...settings, global: entry }
|
|
181
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree facts the drawer header renders, as plain functions.
|
|
3
|
+
*
|
|
4
|
+
* These live outside GitWorkbenchPanel.tsx so they can be tested: importing the
|
|
5
|
+
* panel pulls a CSS module and React, which a node test environment cannot
|
|
6
|
+
* load. The parameter types are structural for the same reason — naming
|
|
7
|
+
* `WorktreeEntry` would import the panel back in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Separator-agnostic form: forward slashes, no trailing separator. */
|
|
11
|
+
function normalize(path: string): string {
|
|
12
|
+
return path.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether two paths name the same place.
|
|
17
|
+
*
|
|
18
|
+
* `git worktree list` reports forward slashes while the session cwd arrives
|
|
19
|
+
* with the platform's own, so a raw comparison reads every Windows worktree as
|
|
20
|
+
* a different directory from itself. A missing side is never equal to anything
|
|
21
|
+
* — the drawer asks this to decide which row is active, and "unknown" must not
|
|
22
|
+
* light one up.
|
|
23
|
+
*/
|
|
24
|
+
export function samePath(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
25
|
+
if (a === null || a === undefined || b === null || b === undefined) return false
|
|
26
|
+
return normalize(a) === normalize(b)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which worktree the panel is reading.
|
|
31
|
+
*
|
|
32
|
+
* The pin is drawer-local by construction, not by remembering to clear it: with
|
|
33
|
+
* the drawer shut there is no way to see which worktree is pinned, change it,
|
|
34
|
+
* or even know one is. State nobody can perceive or reach should not be able to
|
|
35
|
+
* change what they see.
|
|
36
|
+
*
|
|
37
|
+
* Letting it survive a close is what produced a session card reading `main`
|
|
38
|
+
* with a `fixture-03` worktree badge beside it — the branch came from the
|
|
39
|
+
* pinned repository and the badge from the session's binding, so one row was
|
|
40
|
+
* quietly describing two different places. Reopening always discarded the pin
|
|
41
|
+
* anyway, so nothing was being preserved for anyone.
|
|
42
|
+
*
|
|
43
|
+
* @param open - whether the drawer is showing.
|
|
44
|
+
* @param sourcePath - the worktree pinned in the drawer, or null to follow the session.
|
|
45
|
+
* @param sessionPath - the session's own worktree: its binding, else its cwd.
|
|
46
|
+
* @returns the path every fetch and the header card should be about.
|
|
47
|
+
*/
|
|
48
|
+
export function viewedPath(
|
|
49
|
+
open: boolean,
|
|
50
|
+
sourcePath: string | null,
|
|
51
|
+
sessionPath: string | undefined,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
return open ? sourcePath ?? sessionPath : sessionPath
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the cheap closed-drawer binding probe should be on a timer.
|
|
58
|
+
*
|
|
59
|
+
* With the drawer shut, nothing re-read the session's binding: the fetch that
|
|
60
|
+
* carries it is keyed on the session id, the session cwd and `open`, and
|
|
61
|
+
* `worktree_enter` changes none of those — dsh's `session.header.cwd` is
|
|
62
|
+
* immutable, so the binding is the plugin's own state and the sessions store
|
|
63
|
+
* never mentions it. The 3-15s poll that would have caught it starts at
|
|
64
|
+
* `if (!open) return`. So the chip went on saying `main` after the agent had
|
|
65
|
+
* entered a worktree, and only opening the drawer — the one act that flips
|
|
66
|
+
* `open` — brought it up to date.
|
|
67
|
+
*
|
|
68
|
+
* The gate is narrow on purpose. A binding can only move from inside a turn
|
|
69
|
+
* (`worktree_enter` and `worktree_exit` are agent tools), so an idle session has
|
|
70
|
+
* nothing to watch; and this panel is mounted in EVERY session header, so a
|
|
71
|
+
* timer that runs while nothing can change is a per-header cost for no answer.
|
|
72
|
+
*
|
|
73
|
+
* @param open - whether the drawer is showing; open has its own poll.
|
|
74
|
+
* @param agentRunning - whether the session's agent has a turn in flight.
|
|
75
|
+
*/
|
|
76
|
+
export function probesClosedBinding(open: boolean, agentRunning: boolean | undefined): boolean {
|
|
77
|
+
return !open && agentRunning === true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether a binding probe disagrees with the binding the chip is showing.
|
|
82
|
+
*
|
|
83
|
+
* The probe is `gitWorkbench/sessionWorktree`, which reads the bindings JSON and
|
|
84
|
+
* spawns no git at all — that is what makes it affordable on a timer. It cannot
|
|
85
|
+
* replace the status fetch (no worktree list, no branches, so no picker and no
|
|
86
|
+
* badge), so it is used as a change DETECTOR: while the answer holds, the poll
|
|
87
|
+
* costs one file read; the moment it moves, one full `worktreeStatus` refetch
|
|
88
|
+
* repaints everything at once.
|
|
89
|
+
*
|
|
90
|
+
* Paths are compared through {@link samePath} rather than raw. Both sides come
|
|
91
|
+
* from the same file today, but a raw `!==` here would turn one separator
|
|
92
|
+
* difference into a `worktree list` + `branch` pair every three seconds behind
|
|
93
|
+
* a chip that was already correct.
|
|
94
|
+
*
|
|
95
|
+
* @param probe - what the bindings file says now; null fields mean unbound.
|
|
96
|
+
* @param shown - the binding the panel currently renders, or null when unbound.
|
|
97
|
+
*/
|
|
98
|
+
export function bindingChanged(
|
|
99
|
+
probe: { readonly worktreePath: string | null; readonly name: string | null },
|
|
100
|
+
shown: { readonly worktreePath: string; readonly name: string } | null,
|
|
101
|
+
): boolean {
|
|
102
|
+
const probed = probe?.worktreePath ?? null
|
|
103
|
+
const rendered = shown?.worktreePath ?? null
|
|
104
|
+
// Enter and exit both land here: one side has a path and the other does not.
|
|
105
|
+
if (probed === null || rendered === null) return probed !== rendered
|
|
106
|
+
if (!samePath(probed, rendered)) return true
|
|
107
|
+
// Same directory, different name — a re-enter can reuse the path, and the
|
|
108
|
+
// name is what the badge prints.
|
|
109
|
+
return (probe.name ?? '') !== (shown?.name ?? '')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a view should say "pending" rather than show what it has.
|
|
114
|
+
*
|
|
115
|
+
* There are two different loads behind one flag. A worktree SWITCH empties the
|
|
116
|
+
* file list first, so there is genuinely nothing to state and a confident
|
|
117
|
+
* `+0 −1` would be wrong. An ordinary REFRESH — which every tick performs, via
|
|
118
|
+
* `git add` and a refetch — runs over data that is still on screen and still
|
|
119
|
+
* correct; the numbers are about to be replaced by nearly identical ones.
|
|
120
|
+
*
|
|
121
|
+
* Blanking on the raw flag treated those the same, so every tick swapped the
|
|
122
|
+
* header totals for a `—` and back. Measured at 400ms, which is precisely the
|
|
123
|
+
* duration that reads as a flicker rather than as a load.
|
|
124
|
+
*
|
|
125
|
+
* The file tree already had this rule inline. Duplicating it in the header is
|
|
126
|
+
* how the header got it wrong, so it lives here now and both read it.
|
|
127
|
+
*
|
|
128
|
+
* @param loading - whether a fetch for this view is in flight.
|
|
129
|
+
* @param fileCount - how many files the view is currently able to show.
|
|
130
|
+
*/
|
|
131
|
+
export function showsPending(loading: boolean, fileCount: number): boolean {
|
|
132
|
+
return loading && fileCount === 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether a worktree's badge would only repeat the branch chip beside it.
|
|
137
|
+
*
|
|
138
|
+
* dsh derives one from the other — `branchFor` in `src/worktree.ts` turns the
|
|
139
|
+
* name `demo` into the branch `wt/demo` — so for every worktree dsh made, the
|
|
140
|
+
* session card was printing the same word twice, once as `wt/fixture-03` and
|
|
141
|
+
* once as a `fixture-03` badge next to it. A worktree made outside dsh has no
|
|
142
|
+
* such relation, and there the badge is the only thing naming the directory.
|
|
143
|
+
*
|
|
144
|
+
* @param branch - the branch checked out there.
|
|
145
|
+
* @param name - the worktree's name, as the binding records it.
|
|
146
|
+
*/
|
|
147
|
+
export function badgeRepeatsBranch(branch: string, name: string): boolean {
|
|
148
|
+
return branch.length > 0 && branch === `wt/${name}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Split a name into a shrinkable head and the last segment.
|
|
153
|
+
*
|
|
154
|
+
* Ref names are path-shaped too (`feature/nested/deep/some-fix`, and git spells
|
|
155
|
+
* them `refs/heads/...` underneath), so branches elide by the same rule as
|
|
156
|
+
* directories: the leaf is what distinguishes siblings, so the leaf is what
|
|
157
|
+
* survives.
|
|
158
|
+
*
|
|
159
|
+
* The header shows the whole path but has to give way to the totals beside it,
|
|
160
|
+
* and truncating from the right would eat exactly the segment that tells two
|
|
161
|
+
* worktrees apart. So the head ellipsises and the tail never does.
|
|
162
|
+
*
|
|
163
|
+
* Separators are preserved as they arrived: the path is shown to a human who
|
|
164
|
+
* recognises their own machine's spelling, not fed back to git.
|
|
165
|
+
*
|
|
166
|
+
* @param path - an absolute or relative path, possibly with a trailing separator.
|
|
167
|
+
* @returns the directory part including its trailing separator, and the last segment.
|
|
168
|
+
*/
|
|
169
|
+
export function splitPath(path: string): { head: string; tail: string } {
|
|
170
|
+
const trimmed = path.replace(/[/\\]+$/, '')
|
|
171
|
+
if (trimmed.length === 0) return { head: '', tail: path }
|
|
172
|
+
const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
173
|
+
if (cut < 0) return { head: '', tail: trimmed }
|
|
174
|
+
return { head: trimmed.slice(0, cut + 1), tail: trimmed.slice(cut + 1) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The branch checked out at a worktree, from the list already in hand.
|
|
179
|
+
*
|
|
180
|
+
* This is what lets a source switch repaint the header immediately. The stats
|
|
181
|
+
* fetch that follows takes a `git status` and a numstat — seconds on a large
|
|
182
|
+
* repository — and until it lands the panel used to claim `(no branch)`, which
|
|
183
|
+
* is not a slower answer but a wrong one.
|
|
184
|
+
*
|
|
185
|
+
* @returns the branch, or an empty string when the path is unknown or the
|
|
186
|
+
* worktree is detached — both of which genuinely have no branch to name.
|
|
187
|
+
*/
|
|
188
|
+
export function branchOfWorktree(
|
|
189
|
+
path: string | null | undefined,
|
|
190
|
+
worktrees: readonly { readonly path: string; readonly branch: string }[],
|
|
191
|
+
): string {
|
|
192
|
+
return worktrees.find(entry => samePath(entry.path, path))?.branch ?? ''
|
|
193
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded store for payloads addressed by a git object name.
|
|
3
|
+
*
|
|
4
|
+
* A commit hash names content that cannot change, so a hit stays valid for the
|
|
5
|
+
* life of the process and there is nothing to invalidate — capacity is the only
|
|
6
|
+
* reason an entry ever leaves. That is the whole distinction from working-tree
|
|
7
|
+
* data, which must never be cached because the next keystroke can change it.
|
|
8
|
+
*
|
|
9
|
+
* @module @young1lin/dsh-ui-gitworkbench/commit-cache
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compose a cache key from its parts.
|
|
14
|
+
*
|
|
15
|
+
* The separator is a unit separator, which cannot occur in a filesystem path, a
|
|
16
|
+
* git object name, or a repository-relative filename. No two distinct part
|
|
17
|
+
* lists can therefore produce the same key.
|
|
18
|
+
* @param parts - key components, most general first.
|
|
19
|
+
* @returns the composed key.
|
|
20
|
+
*/
|
|
21
|
+
export function cacheKey(...parts: readonly string[]): string {
|
|
22
|
+
return parts.join('\x1f')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fixed-capacity map with least-recently-used eviction.
|
|
27
|
+
*
|
|
28
|
+
* A Map iterates in insertion order, so re-inserting an entry on every hit
|
|
29
|
+
* makes insertion order the recency order and the first key the least recently
|
|
30
|
+
* used one. No timestamps, no separate list.
|
|
31
|
+
*/
|
|
32
|
+
export class CommitPayloadCache<V> {
|
|
33
|
+
private readonly entries = new Map<string, V>()
|
|
34
|
+
|
|
35
|
+
/** @param capacity - entries kept resident; the least recently used is dropped past it. */
|
|
36
|
+
constructor(private readonly capacity: number) {}
|
|
37
|
+
|
|
38
|
+
/** How many entries are resident. */
|
|
39
|
+
get size(): number {
|
|
40
|
+
return this.entries.size
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read an entry, marking it most recently used.
|
|
45
|
+
* @param key - cache key.
|
|
46
|
+
* @returns the stored payload, or undefined when the key is absent.
|
|
47
|
+
*/
|
|
48
|
+
get(key: string): V | undefined {
|
|
49
|
+
const hit = this.entries.get(key)
|
|
50
|
+
if (hit === undefined) return undefined
|
|
51
|
+
this.entries.delete(key)
|
|
52
|
+
this.entries.set(key, hit)
|
|
53
|
+
return hit
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Store an entry as most recently used, evicting past capacity.
|
|
58
|
+
* @param key - cache key.
|
|
59
|
+
* @param value - payload to store, replacing any existing entry for the key.
|
|
60
|
+
*/
|
|
61
|
+
set(key: string, value: V): void {
|
|
62
|
+
this.entries.delete(key)
|
|
63
|
+
this.entries.set(key, value)
|
|
64
|
+
if (this.entries.size > this.capacity) {
|
|
65
|
+
const oldest = this.entries.keys().next()
|
|
66
|
+
if (!oldest.done) this.entries.delete(oldest.value)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|