dsh-git-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/README.zh.md +110 -0
- package/cordis.patch.yml +14 -0
- package/lib/.keep +0 -0
- package/lib/client.js +71 -0
- package/lib/client.js.map +7 -0
- package/lib/host/core.d.ts +53 -0
- package/lib/host/git.d.ts +89 -0
- package/lib/host/index.d.ts +23 -0
- package/lib/host/index.js +380 -0
- package/lib/host/index.js.map +7 -0
- package/lib/host/parser.d.ts +57 -0
- package/lib/host/types.d.ts +74 -0
- package/package.json +80 -0
- package/src/client/GitPill.tsx +354 -0
- package/src/client/controller.ts +158 -0
- package/src/client/index.ts +149 -0
- package/src/client/locales.ts +55 -0
- package/src/client/remote.ts +97 -0
- package/src/client/styles.ts +229 -0
- package/src/client/turn-signal.ts +33 -0
- package/src/host/core.ts +199 -0
- package/src/host/git.ts +152 -0
- package/src/host/index.ts +80 -0
- package/src/host/parser.ts +176 -0
- package/src/host/types.ts +74 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers for the git porcelain/log output shapes used by the widget.
|
|
3
|
+
* No side effects and no I/O — fully unit-testable against literal fixtures
|
|
4
|
+
* (verified against real `git status --porcelain=v1 -z --branch` output).
|
|
5
|
+
*/
|
|
6
|
+
import type { GitChange, GitCommit } from './types.ts';
|
|
7
|
+
/** Parsed status counts plus the (possibly capped) change list. */
|
|
8
|
+
export interface ParsedStatus {
|
|
9
|
+
readonly branch: string | null;
|
|
10
|
+
readonly unborn: boolean;
|
|
11
|
+
readonly staged: number;
|
|
12
|
+
readonly modified: number;
|
|
13
|
+
readonly untracked: number;
|
|
14
|
+
readonly ahead: number;
|
|
15
|
+
readonly behind: number;
|
|
16
|
+
readonly changes: readonly GitChange[];
|
|
17
|
+
readonly truncated: boolean;
|
|
18
|
+
}
|
|
19
|
+
interface StatusHeader {
|
|
20
|
+
readonly branch: string | null;
|
|
21
|
+
readonly unborn: boolean;
|
|
22
|
+
readonly ahead: number;
|
|
23
|
+
readonly behind: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse the `## ` header line of `git status --porcelain=v1 -z --branch`.
|
|
27
|
+
* Recognized shapes (verified against git 2.x):
|
|
28
|
+
* `## main`
|
|
29
|
+
* `## main...origin/main`
|
|
30
|
+
* `## main...origin/main [ahead 1]`
|
|
31
|
+
* `## main...origin/main [behind 2]`
|
|
32
|
+
* `## main...origin/main [ahead 1, behind 2]`
|
|
33
|
+
* `## HEAD (no branch)` (detached)
|
|
34
|
+
* `## HEAD (detached at <hash>)` (detached, older git)
|
|
35
|
+
* `## No commits yet on main` (unborn)
|
|
36
|
+
* `## Initial commit on main` (unborn, older git)
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseStatusHeader(line: string): StatusHeader;
|
|
39
|
+
/**
|
|
40
|
+
* Parse the full `git status --porcelain=v1 -z --branch` output.
|
|
41
|
+
* -z format: every entry (header and each `XY path`) is NUL-terminated; a
|
|
42
|
+
* rename/copy entry emits `R <new>\0<old>\0` so the following item is the
|
|
43
|
+
* source path and must be consumed without becoming a change itself.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseStatusOutput(output: string, maxChanges: number): ParsedStatus;
|
|
46
|
+
/**
|
|
47
|
+
* Parse `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI` output.
|
|
48
|
+
* One commit per line, fields separated by the unit separator; empty output
|
|
49
|
+
* (unborn repository) yields `[]`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseLogOutput(output: string): readonly GitCommit[];
|
|
52
|
+
/**
|
|
53
|
+
* Parse `git branch --show-current` output: the branch name, or null when
|
|
54
|
+
* empty (detached HEAD).
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseBranchOutput(output: string): string | null;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-ui host data model. This file is the authoritative type source;
|
|
3
|
+
* the client half mirrors it with zod schemas (see src/client/remote.ts) and
|
|
4
|
+
* `tests/remote.spec.ts` keeps the two in sync.
|
|
5
|
+
*/
|
|
6
|
+
/** Wire request: the browser never sends paths — only the session identity. */
|
|
7
|
+
export interface GitSnapshotRequest {
|
|
8
|
+
readonly sessionId: string;
|
|
9
|
+
}
|
|
10
|
+
/** Discriminated outcome of one snapshot attempt. */
|
|
11
|
+
export type GitSnapshotResult = {
|
|
12
|
+
readonly ok: true;
|
|
13
|
+
readonly value: GitSnapshot;
|
|
14
|
+
} | {
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly error: GitSnapshotFailure;
|
|
17
|
+
};
|
|
18
|
+
export type GitSnapshotFailure = {
|
|
19
|
+
readonly code: 'session-not-found';
|
|
20
|
+
readonly sessionId: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly code: 'cwd-unavailable';
|
|
23
|
+
readonly sessionId: string;
|
|
24
|
+
} | {
|
|
25
|
+
readonly code: 'path-not-found';
|
|
26
|
+
readonly path: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly code: 'git-unavailable';
|
|
29
|
+
readonly detail: string;
|
|
30
|
+
} | {
|
|
31
|
+
readonly code: 'timeout';
|
|
32
|
+
} | {
|
|
33
|
+
readonly code: 'not-a-git-repo';
|
|
34
|
+
};
|
|
35
|
+
/** Immutable frozen snapshot of one repository's status at `checkedAt`. */
|
|
36
|
+
export interface GitSnapshot {
|
|
37
|
+
/** Realpath of the repository root (work tree top). */
|
|
38
|
+
readonly root: string;
|
|
39
|
+
/** Current branch name; null when detached. */
|
|
40
|
+
readonly branch: string | null;
|
|
41
|
+
/** Short HEAD hash; null when the repository has no commits (unborn). */
|
|
42
|
+
readonly head: string | null;
|
|
43
|
+
/** True when the repository has no commits yet. */
|
|
44
|
+
readonly unborn: boolean;
|
|
45
|
+
/** staged + modified + untracked > 0. */
|
|
46
|
+
readonly dirty: boolean;
|
|
47
|
+
readonly staged: number;
|
|
48
|
+
readonly modified: number;
|
|
49
|
+
readonly untracked: number;
|
|
50
|
+
readonly ahead: number;
|
|
51
|
+
readonly behind: number;
|
|
52
|
+
readonly lastCommit: GitCommit | null;
|
|
53
|
+
readonly recentCommits: readonly GitCommit[];
|
|
54
|
+
readonly changes: readonly GitChange[];
|
|
55
|
+
/** True when `changes` was capped at maxChanges or status output overflowed. */
|
|
56
|
+
readonly truncated: boolean;
|
|
57
|
+
/** Polling interval the client should use after this snapshot (0 = off). */
|
|
58
|
+
readonly refreshIntervalMs: number;
|
|
59
|
+
/** Epoch millis of the snapshot. */
|
|
60
|
+
readonly checkedAt: number;
|
|
61
|
+
}
|
|
62
|
+
export interface GitCommit {
|
|
63
|
+
readonly hash: string;
|
|
64
|
+
readonly shortHash: string;
|
|
65
|
+
readonly subject: string;
|
|
66
|
+
readonly author: string;
|
|
67
|
+
readonly dateIso: string;
|
|
68
|
+
}
|
|
69
|
+
export interface GitChange {
|
|
70
|
+
readonly path: string;
|
|
71
|
+
readonly status: GitChangeStatus;
|
|
72
|
+
readonly staged: boolean;
|
|
73
|
+
}
|
|
74
|
+
export type GitChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' | 'conflicted' | 'typechange';
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-git-ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "DeepSeek Harness (dsh) plugin: visualize Git status in the Web UI — current branch, HEAD, staged/modified/untracked counts, ahead/behind, recent commits and changed files.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Julyves",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+ssh://git@github.com:Julyves/dsh-git-ui.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dsh",
|
|
14
|
+
"deepseek-harness",
|
|
15
|
+
"plugin",
|
|
16
|
+
"git",
|
|
17
|
+
"web-ui",
|
|
18
|
+
"status"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"main": "lib/host/index.js",
|
|
24
|
+
"types": "lib/host/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./lib/host/index.d.ts",
|
|
28
|
+
"default": "./lib/host/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./lib/client/index.d.ts",
|
|
32
|
+
"default": "./lib/client.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"lib",
|
|
38
|
+
"src",
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"README.md",
|
|
41
|
+
"README.zh.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"build": "node build.mjs",
|
|
48
|
+
"prepare": "node build.mjs",
|
|
49
|
+
"pack": "npm pack"
|
|
50
|
+
},
|
|
51
|
+
"dsh": {
|
|
52
|
+
"bundle": {
|
|
53
|
+
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"platform": "web",
|
|
57
|
+
"inject": [
|
|
58
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
59
|
+
"@deepseek-ai/dsh-client-locale",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
66
|
+
"@deepseek-ai/dsh-session": ">=0.0.1-rc.1 <0.2.0",
|
|
67
|
+
"@deepseek-ai/dsh-session-persistence": ">=0.0.1-rc.1 <0.2.0",
|
|
68
|
+
"@deepseek-ai/dsh-subprocess": ">=0.0.1-rc.1 <0.2.0",
|
|
69
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.0.1-rc.1 <0.2.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@types/node": "^26.2.0",
|
|
73
|
+
"@types/react": "^19.2.18",
|
|
74
|
+
"esbuild": "^0.27.0",
|
|
75
|
+
"jsdom": "^27.0.0",
|
|
76
|
+
"typescript": "^7.0.2",
|
|
77
|
+
"vitest": "^4.1.10",
|
|
78
|
+
"zod": "^4.4.3"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git status pill + popup. Consumes the injected per-session controller
|
|
3
|
+
* through the framework-standard observable shape: the slot runtime binds the
|
|
4
|
+
* inject face's `hooks.git` observable into a `useGit` selector hook (see
|
|
5
|
+
* bindInjectHooks in dsh-client-web-react), so the component reads the view
|
|
6
|
+
* with `useGit()` instead of subscribing manually. Renders nothing for
|
|
7
|
+
* cold/no-cwd states and a dimmed placeholder for degraded states.
|
|
8
|
+
*
|
|
9
|
+
* Layout contract: the popup is a fixed-position card portaled to
|
|
10
|
+
* document.body and anchored to the wrapper's rect — exactly the machinery
|
|
11
|
+
* the host's Menu/HoverCard use. It never participates in header layout, so
|
|
12
|
+
* opening it cannot stretch the header.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
15
|
+
import { createPortal } from 'react-dom'
|
|
16
|
+
import type { JSX } from 'react'
|
|
17
|
+
import { completedTurnCount, type TurnSignalSnapshot } from './turn-signal.ts'
|
|
18
|
+
import type { GitObservable, GitView } from './controller.ts'
|
|
19
|
+
import type { GitKey } from './locales.ts'
|
|
20
|
+
import * as css from './styles.ts'
|
|
21
|
+
|
|
22
|
+
// Inject the plugin's interaction styles once (idempotent, browser-only).
|
|
23
|
+
css.ensureGlobalCss()
|
|
24
|
+
|
|
25
|
+
/** Injected business face of the header utility entry. */
|
|
26
|
+
export interface GitInjected {
|
|
27
|
+
hooks: {
|
|
28
|
+
/** The owning Session's git view source. The slot runtime binds this
|
|
29
|
+
* observable into the `useGit` selector hook the component consumes. */
|
|
30
|
+
git: GitObservable<GitView>
|
|
31
|
+
}
|
|
32
|
+
/** Force an immediate re-check (same path as polling). */
|
|
33
|
+
refresh: () => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Selector hook shape the slot runtime binds from `hooks.git`. */
|
|
37
|
+
export type UseGit = <S = GitView>(
|
|
38
|
+
selector?: (view: GitView) => S,
|
|
39
|
+
equality?: (a: S, b: S) => boolean,
|
|
40
|
+
) => S
|
|
41
|
+
|
|
42
|
+
export type { TurnSignalSnapshot } from './turn-signal.ts'
|
|
43
|
+
|
|
44
|
+
/** Full props of the git pill entry (framework kit + our inject + locale). */
|
|
45
|
+
export interface GitPillProps extends GitInjected {
|
|
46
|
+
/** Session scope identity from the standard session kit. */
|
|
47
|
+
readonly sessionId: string
|
|
48
|
+
/** Selector hook bound from `hooks.git` by the slot runtime. */
|
|
49
|
+
readonly useGit: UseGit
|
|
50
|
+
/** Standard session kit selector hook (narrowed to the turn signal). */
|
|
51
|
+
readonly useSession: <S>(selector: (snapshot: TurnSignalSnapshot) => S) => S
|
|
52
|
+
/** Namespace-bound dictionary accessor. */
|
|
53
|
+
readonly t: (key: GitKey) => string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Status chip colors for the changed-file list. */
|
|
57
|
+
const CHIP_COLORS: Record<string, string> = {
|
|
58
|
+
added: '#2e7d32',
|
|
59
|
+
modified: '#b26a00',
|
|
60
|
+
deleted: '#c62828',
|
|
61
|
+
renamed: '#1565c0',
|
|
62
|
+
untracked: '#6a6a6a',
|
|
63
|
+
conflicted: '#d32f2f',
|
|
64
|
+
typechange: '#7b1fa2',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const CHIP_LETTERS: Record<string, string> = {
|
|
68
|
+
added: 'A', modified: 'M', deleted: 'D', renamed: 'R',
|
|
69
|
+
untracked: '?', conflicted: '!', typechange: 'T',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Format a count badge like `+2 −1 ?3` (staged / modified / untracked). */
|
|
73
|
+
function dirtyBadge(snapshot: GitView & { state: 'ready' }): string {
|
|
74
|
+
const parts: string[] = []
|
|
75
|
+
if (snapshot.snapshot.staged > 0) parts.push(`+${snapshot.snapshot.staged}`)
|
|
76
|
+
if (snapshot.snapshot.modified > 0) parts.push(`−${snapshot.snapshot.modified}`)
|
|
77
|
+
if (snapshot.snapshot.untracked > 0) parts.push(`?${snapshot.snapshot.untracked}`)
|
|
78
|
+
return parts.join(' ')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Format ahead/behind like `↑1 ↓2`. */
|
|
82
|
+
function aheadBehind(snapshot: GitView & { state: 'ready' }): string {
|
|
83
|
+
const parts: string[] = []
|
|
84
|
+
if (snapshot.snapshot.ahead > 0) parts.push(`↑${snapshot.snapshot.ahead}`)
|
|
85
|
+
if (snapshot.snapshot.behind > 0) parts.push(`↓${snapshot.snapshot.behind}`)
|
|
86
|
+
return parts.join(' ')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Short relative time from an ISO date, localized through dictionary templates. */
|
|
90
|
+
function timeAgo(iso: string, now: number, t: (key: GitKey) => string): string {
|
|
91
|
+
const then = Date.parse(iso)
|
|
92
|
+
if (!Number.isFinite(then)) return iso
|
|
93
|
+
const seconds = Math.max(0, Math.floor((now - then) / 1000))
|
|
94
|
+
const fill = (template: GitKey, n: number): string => t(template).replace('{n}', String(n))
|
|
95
|
+
if (seconds < 60) return t('time.justNow')
|
|
96
|
+
if (seconds < 3600) return fill('time.minutesAgo', Math.floor(seconds / 60))
|
|
97
|
+
if (seconds < 86_400) return fill('time.hoursAgo', Math.floor(seconds / 3600))
|
|
98
|
+
return fill('time.daysAgo', Math.floor(seconds / 86_400))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The pill label for a ready snapshot. */
|
|
102
|
+
function pillLabel(view: GitView & { state: 'ready' }, t: (key: GitKey) => string): string {
|
|
103
|
+
const s = view.snapshot
|
|
104
|
+
const branch = s.branch === null
|
|
105
|
+
? `(${t('pill.detached')}) · ${s.head ?? ''}`
|
|
106
|
+
: s.branch
|
|
107
|
+
const base = s.unborn ? `${branch} · ${t('pill.noCommits')}` : branch
|
|
108
|
+
const badge = dirtyBadge(view)
|
|
109
|
+
const ahead = aheadBehind(view)
|
|
110
|
+
return [base, badge, ahead].filter(Boolean).join(' · ')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Dimmed pill for degraded states. */
|
|
114
|
+
function DegradedPill({ label, title, t }: { label: string; title?: string; t: (key: GitKey) => string }): JSX.Element {
|
|
115
|
+
void t
|
|
116
|
+
return (
|
|
117
|
+
<span className="dsh-git-ui__pill" style={css.pillDimmed} title={title} aria-label={label}>
|
|
118
|
+
{label}
|
|
119
|
+
</span>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Popup body (rendered inside the portaled card): root, counts, commits, changes, refresh. */
|
|
124
|
+
function GitPopupBody({
|
|
125
|
+
view, refresh, t,
|
|
126
|
+
}: {
|
|
127
|
+
view: GitView & { state: 'ready' }
|
|
128
|
+
refresh: () => Promise<void>
|
|
129
|
+
t: (key: GitKey) => string
|
|
130
|
+
}): JSX.Element {
|
|
131
|
+
const now = Date.now()
|
|
132
|
+
const s = view.snapshot
|
|
133
|
+
return (
|
|
134
|
+
<>
|
|
135
|
+
<h4 style={css.popupTitle}>{t('popup.title')}</h4>
|
|
136
|
+
<div style={css.rootLine} title={s.root}>{s.root}</div>
|
|
137
|
+
<div style={css.countGrid}>
|
|
138
|
+
{([
|
|
139
|
+
['popup.staged', s.staged], ['popup.modified', s.modified], ['popup.untracked', s.untracked],
|
|
140
|
+
['popup.ahead', s.ahead], ['popup.behind', s.behind],
|
|
141
|
+
] as const).map(([key, value]) => (
|
|
142
|
+
<div key={key} style={css.countCell}>
|
|
143
|
+
<div style={css.countValue}>{value}</div>
|
|
144
|
+
<div style={css.countLabel}>{t(key)}</div>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
<div style={css.sectionTitle}>{t('popup.recentCommits')}</div>
|
|
149
|
+
{s.recentCommits.length === 0
|
|
150
|
+
? <div style={css.emptyNote}>{t('popup.emptyCommits')}</div>
|
|
151
|
+
: s.recentCommits.map((commit) => (
|
|
152
|
+
<div key={commit.hash} style={css.commitRow}>
|
|
153
|
+
<span style={css.commitHash}>{commit.shortHash}</span>
|
|
154
|
+
<span style={css.commitSubject} title={commit.subject}>{commit.subject}</span>
|
|
155
|
+
<span style={css.commitMeta}>{commit.author} · {timeAgo(commit.dateIso, now, t)}</span>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
<div style={css.sectionTitle}>{t('popup.changes')}</div>
|
|
159
|
+
{s.changes.length === 0
|
|
160
|
+
? <div style={css.emptyNote}>{t('popup.empty')}</div>
|
|
161
|
+
: (
|
|
162
|
+
<>
|
|
163
|
+
{s.changes.map((change) => (
|
|
164
|
+
<div key={change.path} style={css.changeRow}>
|
|
165
|
+
<span
|
|
166
|
+
style={{ ...css.changeChip, background: CHIP_COLORS[change.status] ?? '#888' }}
|
|
167
|
+
title={change.status}
|
|
168
|
+
>
|
|
169
|
+
{CHIP_LETTERS[change.status] ?? '•'}
|
|
170
|
+
</span>
|
|
171
|
+
<span style={css.changePath} title={change.path}>{change.path}</span>
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
{s.truncated && (
|
|
175
|
+
<div style={css.emptyNote}>{t('popup.changesTruncated').replace('{count}', String(s.changes.length))}</div>
|
|
176
|
+
)}
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
<div style={css.footerRow}>
|
|
180
|
+
<span style={css.checkedAt}>{t('popup.checkedAt').replace('{time}', new Date(s.checkedAt).toLocaleTimeString())}</span>
|
|
181
|
+
<PopRefresher refresh={refresh} t={t} />
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The popup refresh verb (kept as its own component so it may own state). */
|
|
188
|
+
function PopRefresher({ refresh, t }: { refresh: () => Promise<void>; t: (key: GitKey) => string }): JSX.Element {
|
|
189
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
190
|
+
const onRefresh = (): void => {
|
|
191
|
+
if (refreshing) return
|
|
192
|
+
setRefreshing(true)
|
|
193
|
+
void refresh().finally(() => setRefreshing(false))
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<button type="button" className="dsh-git-ui__refresh" style={css.refreshButton} onClick={onRefresh} disabled={refreshing}>
|
|
197
|
+
{refreshing ? '…' : t('popup.refresh')}
|
|
198
|
+
</button>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Popup geometry (matches the host Menu/HoverCard portal pattern).
|
|
203
|
+
const POPUP_WIDTH = 340
|
|
204
|
+
const POPUP_MAX_HEIGHT = 420
|
|
205
|
+
const POPUP_GAP = 6
|
|
206
|
+
const POPUP_GUTTER = 6
|
|
207
|
+
const VIEW_GUTTER = 8
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The header utility entry: a branch pill that opens a portaled detail popup.
|
|
211
|
+
*/
|
|
212
|
+
export function GitPill({ useGit, useSession, refresh, t }: GitPillProps): JSX.Element | null {
|
|
213
|
+
// The selector hook requires a selector function (with-selector calls it
|
|
214
|
+
// unconditionally); identity selection reads the whole view snapshot.
|
|
215
|
+
const view = useGit((view) => view)
|
|
216
|
+
// Last ready snapshot: while a refresh is in flight the controller reports
|
|
217
|
+
// 'loading'; render the previous content instead of unmounting (a null
|
|
218
|
+
// here unmounts the whole entry and makes sibling utilities in the same
|
|
219
|
+
// seat flicker on every poll).
|
|
220
|
+
const lastReady = useRef<GitView & { state: 'ready' } | null>(null)
|
|
221
|
+
if (view.state === 'ready') lastReady.current = view
|
|
222
|
+
const display: GitView = view.state === 'loading' && lastReady.current !== null ? lastReady.current : view
|
|
223
|
+
|
|
224
|
+
// Best-effort activity trigger: an agent turn completing is the most
|
|
225
|
+
// likely moment the working tree changed, so refresh right away instead of
|
|
226
|
+
// waiting for the next poll. Polling stays the fallback (external edits,
|
|
227
|
+
// window-slot misses). The ref starts at the CURRENT count so the mount
|
|
228
|
+
// kick (below) is not duplicated; a session switch remounts and resets it.
|
|
229
|
+
const turnCount = useSession((s) => completedTurnCount(s))
|
|
230
|
+
const lastTurnRef = useRef(turnCount)
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (turnCount <= lastTurnRef.current) return
|
|
233
|
+
lastTurnRef.current = turnCount
|
|
234
|
+
void refresh()
|
|
235
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh is per-session stable by contract.
|
|
236
|
+
}, [turnCount])
|
|
237
|
+
|
|
238
|
+
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
239
|
+
const popRef = useRef<HTMLDivElement>(null)
|
|
240
|
+
const [open, setOpen] = useState(false)
|
|
241
|
+
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
// First mount only: kick the controller once (single-flight; a cold
|
|
245
|
+
// controller loads, a no-cwd controller retries). The inject face is
|
|
246
|
+
// stable per session, so the controller must not be re-kicked on
|
|
247
|
+
// re-renders — polling takes over after the first snapshot.
|
|
248
|
+
void refresh()
|
|
249
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only kick; refresh is per-session stable by contract.
|
|
250
|
+
}, [])
|
|
251
|
+
|
|
252
|
+
// Anchor the popup to the wrapper rect; re-place on scroll/resize while open.
|
|
253
|
+
useLayoutEffect(() => {
|
|
254
|
+
if (!open) {
|
|
255
|
+
setPos(null)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
const wrapper = wrapRef.current
|
|
259
|
+
if (wrapper === null) return
|
|
260
|
+
const place = (): void => {
|
|
261
|
+
const r = wrapper.getBoundingClientRect()
|
|
262
|
+
const left = Math.max(VIEW_GUTTER, Math.min(r.right - POPUP_WIDTH, window.innerWidth - POPUP_WIDTH - VIEW_GUTTER))
|
|
263
|
+
const below = r.bottom + POPUP_GAP
|
|
264
|
+
const top = (below + POPUP_MAX_HEIGHT > window.innerHeight - VIEW_GUTTER)
|
|
265
|
+
? Math.max(VIEW_GUTTER, r.top - POPUP_MAX_HEIGHT - POPUP_GUTTER)
|
|
266
|
+
: below
|
|
267
|
+
setPos({ top, left })
|
|
268
|
+
}
|
|
269
|
+
place()
|
|
270
|
+
window.addEventListener('scroll', place, true)
|
|
271
|
+
window.addEventListener('resize', place)
|
|
272
|
+
return () => {
|
|
273
|
+
window.removeEventListener('scroll', place, true)
|
|
274
|
+
window.removeEventListener('resize', place)
|
|
275
|
+
}
|
|
276
|
+
}, [open])
|
|
277
|
+
|
|
278
|
+
// Reconcile a popup that opened upward with its measured height.
|
|
279
|
+
useLayoutEffect(() => {
|
|
280
|
+
if (!open || pos === null) return
|
|
281
|
+
const h = popRef.current?.offsetHeight ?? POPUP_MAX_HEIGHT
|
|
282
|
+
if (pos.top + h > window.innerHeight - VIEW_GUTTER) {
|
|
283
|
+
setPos({ left: pos.left, top: Math.max(VIEW_GUTTER, window.innerHeight - h - VIEW_GUTTER) })
|
|
284
|
+
}
|
|
285
|
+
}, [open, pos])
|
|
286
|
+
|
|
287
|
+
// Close on outside press or Escape (the popup is portaled to body, so the
|
|
288
|
+
// wrapper ref can't cover it — check the card ref explicitly too).
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (!open) return
|
|
291
|
+
const close = (): void => { setOpen(false); setPos(null) }
|
|
292
|
+
const onDown = (e: MouseEvent): void => {
|
|
293
|
+
const target = e.target as Node
|
|
294
|
+
if (wrapRef.current?.contains(target) ?? false) return
|
|
295
|
+
if (popRef.current?.contains(target) ?? false) return
|
|
296
|
+
close()
|
|
297
|
+
}
|
|
298
|
+
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
|
|
299
|
+
document.addEventListener('mousedown', onDown)
|
|
300
|
+
document.addEventListener('keydown', onKey)
|
|
301
|
+
return () => {
|
|
302
|
+
document.removeEventListener('mousedown', onDown)
|
|
303
|
+
document.removeEventListener('keydown', onKey)
|
|
304
|
+
}
|
|
305
|
+
}, [open])
|
|
306
|
+
|
|
307
|
+
if (display.state === 'cold' || display.state === 'no-cwd') return null
|
|
308
|
+
if (display.state === 'loading') {
|
|
309
|
+
// First load only: nothing to show yet.
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
if (display.state === 'error') {
|
|
313
|
+
if (display.error.code === 'not-a-git-repo') {
|
|
314
|
+
return <DegradedPill label={t('pill.noRepo')} t={t} />
|
|
315
|
+
}
|
|
316
|
+
return (
|
|
317
|
+
<DegradedPill
|
|
318
|
+
label={t('pill.unavailable')}
|
|
319
|
+
title={display.error.code === 'git-unavailable' ? display.error.detail : display.error.code}
|
|
320
|
+
t={t}
|
|
321
|
+
/>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const dirty = display.snapshot.dirty
|
|
326
|
+
return (
|
|
327
|
+
<span ref={wrapRef} style={{ display: 'inline-flex' }}>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
className="dsh-git-ui__pill"
|
|
331
|
+
style={css.pill}
|
|
332
|
+
onClick={() => setOpen(!open)}
|
|
333
|
+
aria-haspopup="dialog"
|
|
334
|
+
aria-expanded={open}
|
|
335
|
+
title={`${display.snapshot.root}\n${pillLabel(display, t)}`}
|
|
336
|
+
>
|
|
337
|
+
<span style={dirty ? css.dotDirty : css.dot} aria-hidden="true" />
|
|
338
|
+
<span>{pillLabel(display, t)}</span>
|
|
339
|
+
</button>
|
|
340
|
+
{open && pos !== null && createPortal(
|
|
341
|
+
<div
|
|
342
|
+
ref={popRef}
|
|
343
|
+
className="dsh-git-ui__pop"
|
|
344
|
+
style={{ ...css.popup, top: pos.top, left: pos.left }}
|
|
345
|
+
role="dialog"
|
|
346
|
+
aria-label={t('popup.title')}
|
|
347
|
+
>
|
|
348
|
+
<GitPopupBody view={display} refresh={refresh} t={t} />
|
|
349
|
+
</div>,
|
|
350
|
+
document.body,
|
|
351
|
+
)}
|
|
352
|
+
</span>
|
|
353
|
+
)
|
|
354
|
+
}
|