dsh-remote-vs 0.10.0 → 0.11.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.
Files changed (38) hide show
  1. package/README.en.md +8 -6
  2. package/README.md +8 -6
  3. package/lib/client.js +3313 -1089
  4. package/lib/index.js +209 -7
  5. package/lib/types/client/api.d.ts +12 -0
  6. package/lib/types/client/exec-output.d.ts +58 -0
  7. package/lib/types/client/i18n.d.ts +1 -1
  8. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  9. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  10. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  11. package/lib/types/client/scm/actions.d.ts +50 -3
  12. package/lib/types/client/scm/commit-model.d.ts +119 -0
  13. package/lib/types/client/store.d.ts +9 -3
  14. package/lib/types/client/vsc/kit.d.ts +16 -0
  15. package/lib/types/git-format.d.ts +5 -0
  16. package/lib/types/git-ops.d.ts +57 -2
  17. package/lib/types/pool.d.ts +9 -0
  18. package/lib/types/protocol.d.ts +23 -0
  19. package/lib/types/timeouts.d.ts +17 -2
  20. package/package.json +1 -1
  21. package/src/client/api.ts +61 -0
  22. package/src/client/exec-output.ts +129 -0
  23. package/src/client/i18n.ts +131 -2
  24. package/src/client/scm/CommitPane.tsx +726 -0
  25. package/src/client/scm/ExecPanel.tsx +124 -0
  26. package/src/client/scm/HistoryPanel.tsx +483 -104
  27. package/src/client/scm/ScmPanel.tsx +247 -9
  28. package/src/client/scm/actions.ts +93 -10
  29. package/src/client/scm/commit-model.ts +317 -0
  30. package/src/client/store.tsx +11 -5
  31. package/src/client/vsc/kit.tsx +71 -0
  32. package/src/client/vsc/vsc.css +647 -0
  33. package/src/git-format.ts +7 -3
  34. package/src/git-ops.ts +83 -7
  35. package/src/pool.ts +84 -4
  36. package/src/protocol.ts +14 -0
  37. package/src/routes.ts +135 -1
  38. package/src/timeouts.ts +18 -2
@@ -0,0 +1,119 @@
1
+ import type { GitCommitFile } from '../../protocol.ts';
2
+ /** The pane's three views, in strip order. */
3
+ export declare const HISTORY_TABS: readonly ["details", "content", "diff"];
4
+ export type HistoryTab = (typeof HISTORY_TABS)[number];
5
+ /** Guard for persisted values (`localStorage` may hold anything). */
6
+ export declare function isHistoryTab(value: unknown): value is HistoryTab;
7
+ /**
8
+ * Keyboard navigation for the pane's tab strip: arrows cycle (wrapping, so the
9
+ * last tab's → returns to the first), Home/End jump to the ends. `undefined`
10
+ * means "not a navigation key" and the caller leaves the event alone.
11
+ */
12
+ export declare function stepHistoryTab(current: HistoryTab, key: string): HistoryTab | undefined;
13
+ /** The state the 差异 tab's lazy read keeps for one commit. */
14
+ export interface PatchLoadState {
15
+ key: string;
16
+ text?: string;
17
+ truncated?: boolean;
18
+ error?: string;
19
+ }
20
+ /**
21
+ * Whether the 差异 tab still has to read the patch for `key`.
22
+ *
23
+ * The subtle case is a SUPERSEDED read: switching the tab or the commit while
24
+ * a read is in flight used to leave the key marker behind with no data, and an
25
+ * equality-only guard (`state.key === key`) then treated that marker as
26
+ * "already loaded" forever — the reported "点击文件后差异一直说没有改动" bug.
27
+ * A marker without text/error therefore means RETRY, never "done".
28
+ */
29
+ export declare function needsPatchLoad(state: PatchLoadState, key: string, tab: HistoryTab): boolean;
30
+ /** One rendered patch line. */
31
+ export interface PatchRow {
32
+ kind: 'context' | 'add' | 'del' | 'skip';
33
+ /** Old-revision line number; null on added lines and skip markers. */
34
+ oldLine: number | null;
35
+ /** New-revision line number; null on deleted lines and skip markers. */
36
+ newLine: number | null;
37
+ /** Line content without the leading ` ` / `+` / `-` marker. */
38
+ text: string;
39
+ }
40
+ /** One file block of a commit patch. */
41
+ export interface CommitPatchFile {
42
+ /** Destination path (the source for deletions). */
43
+ path: string;
44
+ /** Source path when the block is a rename/copy (equals `path` otherwise). */
45
+ oldPath: string;
46
+ status: string;
47
+ binary: boolean;
48
+ added: number;
49
+ deleted: number;
50
+ rows: PatchRow[];
51
+ /** True when the global row cap kept this block's rows (partly) out. */
52
+ truncated: boolean;
53
+ }
54
+ export interface CommitPatchModel {
55
+ files: CommitPatchFile[];
56
+ added: number;
57
+ deleted: number;
58
+ /** True when the row cap cut the model (later blocks may have no rows). */
59
+ truncated: boolean;
60
+ }
61
+ /**
62
+ * Rendered-row ceiling for one commit patch.
63
+ *
64
+ * A commit that rewrites a generated file can carry hundreds of thousands of
65
+ * lines; painting them all freezes the sidebar on a click. The header counts
66
+ * stay exact (they come from the parsed hunks, not from the rendered rows), so
67
+ * the truncation notice only claims what is actually missing: the paint.
68
+ */
69
+ export declare const PATCH_ROW_CAP = 6000;
70
+ /** Build the render model for one commit's `git show --patch` text. */
71
+ export declare function buildCommitPatch(text: string, rowCap?: number): CommitPatchModel;
72
+ /** The file block a path selects in the diff view (rename-aware). */
73
+ export declare function blockForPath(model: CommitPatchModel, path: string): string | undefined;
74
+ /**
75
+ * Split one commit patch into its per-file raw texts, in the SAME order
76
+ * `buildCommitPatch` reports the files.
77
+ *
78
+ * Used by "copy this file's patch": the view still holds the original text, so
79
+ * the exact bytes git produced are what lands on the clipboard — no
80
+ * re-serialization from rows, which would drop headers a future git adds.
81
+ */
82
+ export declare function splitCommitPatch(text: string): string[];
83
+ /** One directory of the 内容 tree ('' = repository root). */
84
+ export interface CommitFileGroup {
85
+ dir: string;
86
+ files: GitCommitFile[];
87
+ }
88
+ /**
89
+ * Group one commit's files by directory for the 内容 tree: a commit touching
90
+ * 40 files in 6 folders is a wall of paths as a flat list, and the folder row
91
+ * is also what lets a reader collapse the part they do not care about.
92
+ * Root-level files come first; every level keeps git's own order by path.
93
+ */
94
+ export declare function groupCommitFiles(files: readonly GitCommitFile[]): CommitFileGroup[];
95
+ /**
96
+ * The next path in `paths` from `current` (wrapping at both ends), the diff
97
+ * toolbar's prev/next-file math. `current` unknown → the first/last path, so a
98
+ * fresh pane steps into the list instead of doing nothing.
99
+ */
100
+ export declare function stepPath(paths: readonly string[], current: string | undefined, direction: 1 | -1): string | undefined;
101
+ /**
102
+ * Default folding for the 差异 tab: keep the first `rowBudget` rendered rows
103
+ * open, fold every file after that.
104
+ *
105
+ * A 40-file commit painting up to `PATCH_ROW_CAP` rows in a pane that may be
106
+ * 80px tall is a scroll wall where the reader sees none of the topology; the
107
+ * reference opens a curated subset for the same reason. Folding the TAIL (not
108
+ * per-file size) keeps the reading order intact: the files you scrolled to are
109
+ * still in the same place, just collapsed, and one click opens any of them.
110
+ */
111
+ export declare function autoFoldCommitPatch(model: CommitPatchModel, rowBudget?: number): string[];
112
+ /**
113
+ * Ref badge decoration for one `%D` entry (shared by the graph rows and the
114
+ * commit card). Split for the same reason the history panel split them: the
115
+ * CSS modifier and the display text are different jobs.
116
+ */
117
+ export declare function refBadgeClass(ref: string): string;
118
+ /** Display text of one `%D` entry (`HEAD -> main` → `main`, `tag: v1` → `v1`). */
119
+ export declare function refBadgeLabel(ref: string): string;
@@ -240,7 +240,7 @@ export interface ExplorerValue {
240
240
  /** 停靠回主界面(底层的中心视图或侧栏表面)。 */
241
241
  dockFloatingEditor: () => void;
242
242
  openDiff: (rel: string, staged: boolean, untracked?: boolean) => void;
243
- openCommitFile: (hash: string, rel: string) => void;
243
+ openCommitFile: (hash: string, rel: string, origPath?: string) => void;
244
244
  activateTab: (id: string) => void;
245
245
  closeTab: (id: string) => void;
246
246
  updateTab: (id: string, patch: Partial<EditorTab>) => void;
@@ -547,8 +547,14 @@ export declare function openRemoteFileInBinding(bindingId: string, remotePath: s
547
547
  export declare function isEditorTakeoverActive(): boolean;
548
548
  /** Open (or focus) a working-tree diff of one repository path. */
549
549
  export declare function openDiff(rel: string, staged: boolean, untracked?: boolean): void;
550
- /** Open (or focus) one file of one commit (`ref^` against `ref`). */
551
- export declare function openCommitFile(hash: string, rel: string): void;
550
+ /**
551
+ * Open (or focus) one file of one commit (`ref^` against `ref`).
552
+ *
553
+ * `origPath` is the rename source from the commit's file row / patch header:
554
+ * the two-sided view must diff the OLD path against the NEW one, or git sees
555
+ * two unrelated paths and paints the rename as a whole-file addition.
556
+ */
557
+ export declare function openCommitFile(hash: string, rel: string, origPath?: string): void;
552
558
  export declare function activateTab(id: string): void;
553
559
  export declare function closeTab(id: string): void;
554
560
  export declare function updateTab(id: string, next: Partial<EditorTab>): void;
@@ -17,6 +17,14 @@ type IconProps = {
17
17
  className?: string;
18
18
  };
19
19
  export declare function IconChevronRight(props: IconProps): ReactNode;
20
+ /** Mirror of {@link IconChevronRight}, for the commit pane's "previous file". */
21
+ export declare function IconChevronLeft(props: IconProps): ReactNode;
22
+ /** Expand: two arrows pushing the corners apart (the pane's full-height toggle). */
23
+ export declare function IconExpand(props: IconProps): ReactNode;
24
+ /** Restore: the same two arrows pulling the corners back in. */
25
+ export declare function IconCollapse(props: IconProps): ReactNode;
26
+ /** Wrap long lines: a line turning back on itself instead of running off. */
27
+ export declare function IconWrap(props: IconProps): ReactNode;
20
28
  export declare function IconChevronDown(props: IconProps): ReactNode;
21
29
  /** Mirror of `IconChevronDown`, for the diff's "previous change" button. */
22
30
  export declare function IconChevronUp(props: IconProps): ReactNode;
@@ -29,6 +37,14 @@ export declare function IconPlus(props: IconProps): ReactNode;
29
37
  export declare function IconNewFolder(props: IconProps): ReactNode;
30
38
  export declare function IconTrash(props: IconProps): ReactNode;
31
39
  export declare function IconClose(props: IconProps): ReactNode;
40
+ /** Stop: a filled square — the universal "kill this run" mark. */
41
+ export declare function IconStop(props: IconProps): ReactNode;
42
+ /** Copy glyph: two offset sheets, the front one the clipboard's payload. */
43
+ export declare function IconCopy(props: IconProps): ReactNode;
44
+ /** Author glyph for the commit pane's metadata rows. */
45
+ export declare function IconUser(props: IconProps): ReactNode;
46
+ /** Date glyph, the pair of {@link IconUser} in the commit pane's metadata. */
47
+ export declare function IconClock(props: IconProps): ReactNode;
32
48
  export declare function IconSearch(props: IconProps): ReactNode;
33
49
  /**
34
50
  * "Search file NAMES": a page with a magnifier tucked into its lower-right
@@ -30,5 +30,10 @@ export declare function parseStatus(stdout: string): GitStatus;
30
30
  export declare function parseLog(stdout: string): GitCommit[];
31
31
  /** Parse for-each-ref branch lines. */
32
32
  export declare function parseBranches(stdout: string, current: string): GitBranch[];
33
+ /** Split a raw `%B` message into its subject line and the body below it. */
34
+ export declare function parseMessage(text: string): {
35
+ subject: string;
36
+ body: string;
37
+ };
33
38
  /** Assemble commit detail from message + stat outputs. */
34
39
  export declare function parseShow(hash: string, message: string, stat: string): GitShow;
@@ -13,6 +13,15 @@ export declare function assertBranchName(name: string): string;
13
13
  export declare function assertRemoteName(name: string): string;
14
14
  /** Assert a remote URL (anything printable without control characters). */
15
15
  export declare function assertRemoteUrl(url: string): string;
16
+ /**
17
+ * Assert a push refspec (`HEAD:refs/for/master`, `+refs/heads/x:refs/heads/y`).
18
+ *
19
+ * The charset is the union git accepts on the two sides of the optional colon,
20
+ * plus the force marker and the `{}`/`~`/`^`/`*` revision syntax. `assertRef`
21
+ * cannot be reused: it deliberately rejects `:`, and the whole point of a
22
+ * custom refspec is the destination side (Gerrit's `refs/for/<branch>`).
23
+ */
24
+ export declare function assertPushRefspec(refspec: string): string;
16
25
  /** Resolve the enclosing repository root (fails outside a work tree). */
17
26
  export declare function repoProbeCommand(workdir: string): string;
18
27
  /**
@@ -97,8 +106,35 @@ export interface DiffRequest {
97
106
  }
98
107
  /** Unified diff (or a diffstat) for one side of the working tree. */
99
108
  export declare function diffCommand(workdir: string, request?: DiffRequest): string;
100
- /** Files touched by one commit (`--name-status`, no patch body). */
109
+ /**
110
+ * Files touched by one commit (`--name-status`, no patch body).
111
+ *
112
+ * `--first-parent` is what makes a MERGE commit show anything: plain
113
+ * `git show --name-status <merge>` prints no file rows at all (measured), so
114
+ * the pane said "这个提交没有改动文件" for every merge. With it, the diff is
115
+ * against the first parent — the merge's own contribution — and non-merge
116
+ * commits are byte-for-byte unchanged.
117
+ */
101
118
  export declare function commitFilesCommand(workdir: string, ref: string): string;
119
+ /**
120
+ * Whole patch of one commit, with the commit header stripped (`--format=`), so
121
+ * the output starts at the first `diff --git` line and parses as a plain
122
+ * unified diff. Capped like every read-only builder: a commit that rewrites a
123
+ * generated file would otherwise ship megabytes for one sidebar pane.
124
+ * `--first-parent` makes merge commits readable (see `commitFilesCommand`).
125
+ */
126
+ export declare function commitPatchCommand(workdir: string, ref: string): string;
127
+ /**
128
+ * One changed file's patch inside a commit, with rename detection
129
+ * (`-M`) across BOTH sides of the path.
130
+ *
131
+ * The plain `git diff <hash>^ -- <newPath>` the editor drill-down used to run
132
+ * is wrong for a rename: the old path never matches, so a 100% rename renders
133
+ * as an all-added file. Passing both paths to `git show -M` lets git report
134
+ * the rename the commit actually recorded. Restricted to `paths` so a giant
135
+ * commit still ships one file.
136
+ */
137
+ export declare function commitFileDiffCommand(workdir: string, ref: string, paths: readonly string[]): string;
102
138
  /** One file's content at a revision (`rev` may also be `:0` for the index). */
103
139
  export declare function fileAtRevCommand(workdir: string, rev: string, path: string): string;
104
140
  /** Local + remote-tracking branches with their upstream and tip. */
@@ -137,12 +173,31 @@ export declare function renameBranchCommand(workdir: string, from: string, to: s
137
173
  export declare function mergeCommand(workdir: string, ref: string): string;
138
174
  /** Publish a branch to a remote and set its upstream. */
139
175
  export declare function publishBranchCommand(workdir: string, branch: string, remote: string): string;
140
- /** Fetch every remote, pruning deleted tracking refs. */
176
+ /**
177
+ * Fetch every remote, pruning deleted tracking refs.
178
+ *
179
+ * NOTE: the four NETWORK verbs deliberately do NOT use `statusOk()`. That
180
+ * wrapper buffers git's output in temp files and cats them back only after the
181
+ * command exits, which made the live-output panel sit on "waiting…" for the
182
+ * whole run and then dump everything at once (measured: a 59 s push produced
183
+ * 0.2 s of output after completion). No pipe is involved here, so the shell
184
+ * returns git's own exit status directly, and `--progress` forces the progress
185
+ * lines even though the remote side is not a TTY.
186
+ */
141
187
  export declare function fetchCommand(workdir: string): string;
142
188
  /** Pull the current branch (merge, no editor prompt). */
143
189
  export declare function pullCommand(workdir: string): string;
144
190
  /** Push the current branch. */
145
191
  export declare function pushCommand(workdir: string): string;
192
+ /**
193
+ * Push to an explicit remote with an explicit refspec.
194
+ *
195
+ * This is the Gerrit shape (`git push origin HEAD:refs/for/master`): the plain
196
+ * `git push` above only ever talks to the branch's upstream, so a review
197
+ * workflow had to drop into a terminal. The remote is explicit because a
198
+ * refspec says nothing about which remote it belongs to.
199
+ */
200
+ export declare function pushRefspecCommand(workdir: string, remote: string, refspec: string): string;
146
201
  /** Stash the working tree (optionally including untracked files). */
147
202
  export declare function stashSaveCommand(workdir: string, message: string, includeUntracked: boolean): string;
148
203
  /** Apply (and keep) a stash entry. */
@@ -3,6 +3,15 @@ import type { RemoteStore } from './store.ts';
3
3
  import type { RemoteExecResult } from './protocol.ts';
4
4
  export interface ExecOptions {
5
5
  timeoutMs?: number;
6
+ /**
7
+ * Live sink for the command's output (the streaming network verbs). Called
8
+ * with already-decoded text so a multi-byte character split across ssh2
9
+ * chunks is never mangled; the accumulated `stdout`/`stderr` on the result
10
+ * keep the same bytes.
11
+ */
12
+ onData?: (stream: 'out' | 'err', text: string) => void;
13
+ /** Receives the cancel handle once the run is under way (see `cancel`). */
14
+ onCancel?: (cancel: () => void) => void;
6
15
  /**
7
16
  * Internal: send the line exactly as given — no session wrapper, and no group
8
17
  * kill when it times out. Only the two commands the pool sends on its own
@@ -9,6 +9,8 @@ export declare const REMOTE_API: {
9
9
  readonly test: "/api/dsh-remote/test";
10
10
  readonly keygen: "/api/dsh-remote/keygen";
11
11
  readonly exec: "/api/dsh-remote/exec";
12
+ /** Streaming exec (server-sent events) for the long network verbs. */
13
+ readonly execStream: "/api/dsh-remote/exec-stream";
12
14
  readonly files: "/api/dsh-remote/files";
13
15
  /** Streaming binary upload: raw request body, host and path in the query. */
14
16
  readonly upload: "/api/dsh-remote/upload";
@@ -212,6 +214,27 @@ export interface RemoteExecResult {
212
214
  /** Connection error message when the command never ran. */
213
215
  error?: string | undefined;
214
216
  }
217
+ /**
218
+ * One server-sent event of the streaming exec route.
219
+ *
220
+ * Used by the long network verbs (fetch / pull / push / sync) so the live git
221
+ * output can be shown while the command is still running; the unary exec route
222
+ * keeps serving everything that only needs the result.
223
+ */
224
+ export type ExecStreamEvent = {
225
+ t: 'out';
226
+ d: string;
227
+ } | {
228
+ t: 'err';
229
+ d: string;
230
+ } | {
231
+ t: 'exit';
232
+ success: boolean;
233
+ exitCode: number | null;
234
+ timedOut: boolean;
235
+ durationMs: number;
236
+ error?: string;
237
+ };
215
238
  /** Client -> server terminal frames (JSON text). */
216
239
  export type TerminalClientFrame = {
217
240
  t: 'in';
@@ -19,8 +19,14 @@
19
19
  import type { RemoteTimeouts } from './protocol.ts';
20
20
  /** Smallest accepted timeout: below this, local work would start failing. */
21
21
  export declare const MIN_TIMEOUT_MS = 1000;
22
- /** Largest accepted timeout — the exec route clamps to the same ceiling. */
23
- export declare const MAX_TIMEOUT_MS = 600000;
22
+ /**
23
+ * Largest accepted timeout — the exec route clamps to the same ceiling.
24
+ *
25
+ * Raised from 10 to 30 minutes when the SCM push gained a live output panel:
26
+ * a Gerrit push on a large repo waits for server-side hooks and can outlast
27
+ * the old ceiling with nothing wrong on either side.
28
+ */
29
+ export declare const MAX_TIMEOUT_MS = 1800000;
24
30
  /**
25
31
  * Default for one git command (status/log/branches/diff over exec).
26
32
  *
@@ -30,6 +36,15 @@ export declare const MAX_TIMEOUT_MS = 600000;
30
36
  * plausible cold index walk.
31
37
  */
32
38
  export declare const DEFAULT_GIT_TIMEOUT_MS = 300000;
39
+ /**
40
+ * Floor for the NETWORK verbs (fetch / pull / push / sync).
41
+ *
42
+ * These wait on the far end's hooks and pack transfer, not on this machine, so
43
+ * their budget starts at 20 minutes and grows with `gitTimeoutMs × 2` up to
44
+ * `MAX_TIMEOUT_MS`. The old formula started at 5 minutes and capped at 10, which
45
+ * killed healthy Gerrit pushes.
46
+ */
47
+ export declare const DEFAULT_NETWORK_TIMEOUT_MS = 1200000;
33
48
  /** Default for one SFTP file read (editor, diff sides, document preview). */
34
49
  export declare const DEFAULT_READ_TIMEOUT_MS = 120000;
35
50
  /** The pair every half starts from before any Host answer arrives. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote-vs",
3
3
  "description": "VSCode-style remote development for DSH: SSH host manager (key/password), helper deploy pipeline with version check, remote file explorer + editor + terminal in dsh-better-sidebar.",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
package/src/client/api.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import {
5
5
  REMOTE_API,
6
+ type ExecStreamEvent,
6
7
  type GitBranch,
7
8
  type GitCommit,
8
9
  type GitShow,
@@ -23,6 +24,7 @@ import {
23
24
  type RemoteWorkspaceBinding,
24
25
  } from '../protocol.ts'
25
26
  import { DEFAULT_TIMEOUTS, normalizeTimeouts, timeoutSeconds } from '../timeouts.ts'
27
+ import { parseSseFrame, takeSseFrames } from './exec-output.ts'
26
28
  import {
27
29
  branchesCommand,
28
30
  gitFailed,
@@ -230,6 +232,65 @@ export const api = {
230
232
  // -- exec
231
233
  exec: (alias: string, command: string, timeoutMs?: number) =>
232
234
  post<RemoteExecResult>(REMOTE_API.exec, timeoutMs === undefined ? { alias, command } : { alias, command, timeoutMs }),
235
+ /**
236
+ * Streaming exec: the network verbs' live log.
237
+ *
238
+ * The route answers as SSE frames; `onData` receives decoded text while the
239
+ * command runs, and the promise settles on the `exit` frame. An aborted
240
+ * `signal` cancels the remote process group on the host side (the route acts
241
+ * on the closed response), so a cancel button can actually stop a push.
242
+ */
243
+ execStream: async (
244
+ alias: string,
245
+ command: string,
246
+ timeoutMs: number,
247
+ handlers: { onData?: (stream: 'out' | 'err', text: string) => void; signal?: AbortSignal },
248
+ ): Promise<RemoteExecResult> => {
249
+ const response = await fetch(REMOTE_API.execStream, {
250
+ method: 'POST',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ alias, command, timeoutMs }),
253
+ ...(handlers.signal === undefined ? {} : { signal: handlers.signal }),
254
+ })
255
+ if (!response.ok || response.body === null) {
256
+ let message = `request failed: HTTP ${response.status}`
257
+ try {
258
+ const data = await response.json() as { error?: unknown }
259
+ if (typeof data.error === 'string') message = data.error
260
+ } catch {
261
+ // Keep the status-based message.
262
+ }
263
+ throw new RemoteApiError(response.status, message)
264
+ }
265
+ const reader = response.body.getReader()
266
+ const decoder = new TextDecoder()
267
+ let buffer = ''
268
+ let exit: Extract<ExecStreamEvent, { t: 'exit' }> | undefined
269
+ for (;;) {
270
+ const { done, value } = await reader.read()
271
+ if (done) break
272
+ buffer += decoder.decode(value, { stream: true })
273
+ const { frames, rest } = takeSseFrames(buffer)
274
+ buffer = rest
275
+ for (const frame of frames) {
276
+ const payload = parseSseFrame(frame)
277
+ if (payload === null || typeof payload !== 'object') continue
278
+ const event = payload as ExecStreamEvent
279
+ if (event.t === 'out' || event.t === 'err') handlers.onData?.(event.t, event.d)
280
+ else if (event.t === 'exit') exit = event
281
+ }
282
+ }
283
+ if (exit === undefined) throw new RemoteApiError(502, 'stream ended without an exit frame')
284
+ return {
285
+ success: exit.success,
286
+ exitCode: exit.exitCode,
287
+ timedOut: exit.timedOut,
288
+ stdout: '',
289
+ stderr: '',
290
+ durationMs: exit.durationMs,
291
+ ...(exit.error === undefined ? {} : { error: exit.error }),
292
+ }
293
+ },
233
294
  // -- workspaces
234
295
  getWorkspaces: () => call<{ workspaces: RemoteWorkspaceBinding[] } & RemoteRoutingStatus & RemotePluginMeta>(REMOTE_API.workspaces),
235
296
  createWorkspace: (payload: { name: string; alias: string; remotePath: string }) =>
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure helpers behind the streaming exec surface (the network verbs' live log).
3
+ *
4
+ * The host route answers as server-sent events so a 20-minute push can show its
5
+ * progress (`remote: …`, hook output, pack lines) while it runs, instead of one
6
+ * line after the fact. Everything here is pure so the local self-check can pin
7
+ * the frame splitting (a chunk boundary may fall anywhere) and the output cap
8
+ * without a browser or a host.
9
+ */
10
+ import { DEFAULT_NETWORK_TIMEOUT_MS, MAX_TIMEOUT_MS } from '../timeouts.ts'
11
+ import { fetchCommand, pullCommand, pushCommand, pushRefspecCommand } from '../git-ops.ts'
12
+
13
+ /** What a streamed push should do: remembered refspec ('' = plain) + remote. */
14
+ export interface PushTarget {
15
+ refspec: string
16
+ remote: string
17
+ }
18
+
19
+ /**
20
+ * The commands one network verb runs, in order.
21
+ *
22
+ * Pure so the gate can pin the sequence: sync is pull-then-push (the historical
23
+ * order), and a push with a refspec must not fall back to the bare form.
24
+ */
25
+ export function networkCommands(root: string, kind: 'fetch' | 'pull' | 'push' | 'sync', push: PushTarget): string[] {
26
+ if (kind === 'fetch') return [fetchCommand(root)]
27
+ if (kind === 'pull') return [pullCommand(root)]
28
+ const pushCmd = push.refspec === '' ? pushCommand(root) : pushRefspecCommand(root, push.remote, push.refspec)
29
+ if (kind === 'push') return [pushCmd]
30
+ // Sync keeps its historical order, and honours a refspec when one is given
31
+ // (the UI currently passes '' for sync, but the API must not quietly drop it).
32
+ return [pullCommand(root), pushCmd]
33
+ }
34
+
35
+ /** Live-log cap: keep the TAIL, because the head of a long push is never the point. */
36
+ export const EXEC_OUTPUT_CAP = 200_000
37
+
38
+ /**
39
+ * Fold carriage-return progress rewrites for DISPLAY.
40
+ *
41
+ * git's progress lines are written as `Counting: 1%\rCounting: 2%\r…` and a
42
+ * `<pre>` would render every rewrite as its own line (hundreds of them for one
43
+ * push). Only the content after the last `\r` of a line survives.
44
+ *
45
+ * Fold at RENDER time, over the complete accumulated text — not inside
46
+ * `appendExecOutput`. A chunk can end mid-rewrite (git writes bare `\r`s at
47
+ * arbitrary boundaries), and folding a fragment loses the state that the next
48
+ * fragment continues: measured against real `git push --progress` stderr, the
49
+ * per-chunk fold glued `Counting: 1%Counting: 2%…` into one long line.
50
+ */
51
+ export function foldExecOutput(text: string): string {
52
+ if (!text.includes('\r')) return text
53
+ return text
54
+ .split('\n')
55
+ .map(line => {
56
+ if (!line.includes('\r')) return line
57
+ // A trailing `\r` (progress finished, then newline) does not erase what
58
+ // was drawn before it: the visible text is the last NON-EMPTY rewrite.
59
+ const parts = line.split('\r')
60
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
61
+ const part = parts[index] as string
62
+ if (part !== '') return part
63
+ }
64
+ return ''
65
+ })
66
+ .join('\n')
67
+ }
68
+
69
+ /**
70
+ * Append one decoded chunk, keeping the TAIL.
71
+ *
72
+ * The raw `\r` bytes are preserved on purpose: the view folds them once, over
73
+ * the whole accumulated log, so a rewrite split across two chunks still folds
74
+ * correctly (`foldExecOutput`).
75
+ */
76
+ export function appendExecOutput(previous: string, chunk: string, cap = EXEC_OUTPUT_CAP): string {
77
+ if (chunk === '') return previous
78
+ const combined = previous + chunk
79
+ if (combined.length <= cap) return combined
80
+ return combined.slice(combined.length - cap)
81
+ }
82
+
83
+ /**
84
+ * Split an SSE buffer into complete frames and the incomplete tail.
85
+ *
86
+ * A chunk can end in the middle of `data: {...}` or between the two newlines
87
+ * that terminate a frame, so the caller must carry `rest` into the next read.
88
+ */
89
+ export function takeSseFrames(buffer: string): { frames: string[]; rest: string } {
90
+ const parts = buffer.split('\n\n')
91
+ const rest = parts.pop() ?? ''
92
+ return { frames: parts.filter(frame => frame.trim() !== ''), rest }
93
+ }
94
+
95
+ /**
96
+ * One SSE frame's JSON payload; `undefined` for comments (heartbeats), empty
97
+ * frames and malformed JSON — a broken heartbeat must not kill the stream.
98
+ */
99
+ export function parseSseFrame(frame: string): unknown | undefined {
100
+ const data = frame
101
+ .split('\n')
102
+ .filter(line => line.startsWith('data:'))
103
+ .map(line => line.slice(5).trimStart())
104
+ .join('\n')
105
+ if (data === '') return undefined
106
+ try {
107
+ return JSON.parse(data) as unknown
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ /** `m:ss` (or `h:mm:ss`) for the running timer. */
114
+ export function formatElapsed(ms: number): string {
115
+ const total = Math.max(0, Math.floor(ms / 1000))
116
+ const seconds = total % 60
117
+ const minutes = Math.floor(total / 60) % 60
118
+ const hours = Math.floor(total / 3600)
119
+ const pad = (value: number): string => String(value).padStart(2, '0')
120
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
121
+ }
122
+
123
+ /**
124
+ * The network verbs' budget: at least 20 minutes, or twice the configured git
125
+ * budget when that is larger, never past the host's 30-minute ceiling.
126
+ */
127
+ export function networkBudgetMs(gitTimeoutMs: number): number {
128
+ return Math.min(MAX_TIMEOUT_MS, Math.max(DEFAULT_NETWORK_TIMEOUT_MS, gitTimeoutMs * 2))
129
+ }