dsh-remote-vs 0.9.3 → 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 (44) hide show
  1. package/README.en.md +13 -10
  2. package/README.md +14 -9
  3. package/cordis.patch.yml +10 -5
  4. package/lib/client.js +3319 -1093
  5. package/lib/index.js +211 -9
  6. package/lib/types/client/api.d.ts +12 -0
  7. package/lib/types/client/composer.d.ts +5 -1
  8. package/lib/types/client/exec-output.d.ts +58 -0
  9. package/lib/types/client/i18n.d.ts +1 -1
  10. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  11. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  12. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  13. package/lib/types/client/scm/actions.d.ts +50 -3
  14. package/lib/types/client/scm/commit-model.d.ts +119 -0
  15. package/lib/types/client/store.d.ts +9 -3
  16. package/lib/types/client/vsc/kit.d.ts +16 -0
  17. package/lib/types/git-format.d.ts +5 -0
  18. package/lib/types/git-ops.d.ts +57 -2
  19. package/lib/types/index.d.ts +5 -0
  20. package/lib/types/pool.d.ts +9 -0
  21. package/lib/types/protocol.d.ts +23 -0
  22. package/lib/types/timeouts.d.ts +17 -2
  23. package/package.json +37 -37
  24. package/src/client/SettingsHosts.tsx +2 -1
  25. package/src/client/api.ts +61 -0
  26. package/src/client/composer.ts +11 -2
  27. package/src/client/exec-output.ts +129 -0
  28. package/src/client/i18n.ts +133 -4
  29. package/src/client/scm/CommitPane.tsx +726 -0
  30. package/src/client/scm/ExecPanel.tsx +124 -0
  31. package/src/client/scm/HistoryPanel.tsx +483 -104
  32. package/src/client/scm/ScmPanel.tsx +247 -9
  33. package/src/client/scm/actions.ts +93 -10
  34. package/src/client/scm/commit-model.ts +317 -0
  35. package/src/client/store.tsx +13 -6
  36. package/src/client/vsc/kit.tsx +71 -0
  37. package/src/client/vsc/vsc.css +647 -0
  38. package/src/git-format.ts +7 -3
  39. package/src/git-ops.ts +83 -7
  40. package/src/index.ts +10 -2
  41. package/src/pool.ts +84 -4
  42. package/src/protocol.ts +14 -0
  43. package/src/routes.ts +135 -1
  44. package/src/timeouts.ts +18 -2
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, re
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { StringDecoder } from "node:string_decoder";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { WebSocketServer } from "ws";
9
10
  import { execFile } from "node:child_process";
@@ -505,6 +506,40 @@ var RemotePool = class {
505
506
  let stream;
506
507
  let out = "";
507
508
  let err = "";
509
+ /** Set by the cancel handle: closed + group-killed, reported as error. */
510
+ let cancelled = false;
511
+ const outDecoder = new StringDecoder("utf8");
512
+ const errDecoder = new StringDecoder("utf8");
513
+ /** Wrapper marker never reaches the live log (see the data handler). */
514
+ let pendingForward = "";
515
+ const forward = (kind, text) => {
516
+ if (text !== "" && opts.onData !== void 0) opts.onData(kind, text);
517
+ };
518
+ /**
519
+ * Cancel the run: close our channel and kill the remote process group.
520
+ *
521
+ * `finish` reports it as an error rather than a timeout — "the user
522
+ * stopped it" and "the budget ran out" are different stories, and the
523
+ * UI must not blame the host for the former.
524
+ */
525
+ const cancel = () => {
526
+ if (settled) return;
527
+ cancelled = true;
528
+ try {
529
+ stream?.close();
530
+ } catch {}
531
+ if (pgid !== void 0) terminateGroup();
532
+ else if (!session) warnOnce();
533
+ finish({
534
+ success: false,
535
+ exitCode: null,
536
+ timedOut: false,
537
+ stdout: out,
538
+ stderr: err,
539
+ error: "cancelled by user"
540
+ });
541
+ };
542
+ opts.onCancel?.(cancel);
508
543
  conn.exec(session ? sessionLine(command) : command, (execError, channel) => {
509
544
  if (execError !== void 0 && execError !== null) {
510
545
  finish({
@@ -527,6 +562,22 @@ var RemotePool = class {
527
562
  channel.on("close", (code, signalName) => {
528
563
  if (code !== null && code !== void 0 || signalName !== null && signalName !== void 0) exited = true;
529
564
  if (timedOut && session && pgid === void 0) warnOnce();
565
+ if (pendingForward !== "") {
566
+ const newline = pendingForward.indexOf("\n");
567
+ const visible = markerPending ? pendingForward.startsWith(PGID_MARKER) ? newline < 0 ? "" : pendingForward.slice(newline + 1) : pendingForward : pendingForward;
568
+ pendingForward = "";
569
+ forward("out", visible);
570
+ }
571
+ const tailOut = outDecoder.end();
572
+ const tailErr = errDecoder.end();
573
+ if (tailOut !== "") {
574
+ out = appendCapped(out, tailOut);
575
+ forward("out", tailOut);
576
+ }
577
+ if (tailErr !== "") {
578
+ err = appendCapped(err, tailErr);
579
+ forward("err", tailErr);
580
+ }
530
581
  finish({
531
582
  success: code === 0,
532
583
  exitCode: code,
@@ -536,18 +587,37 @@ var RemotePool = class {
536
587
  });
537
588
  });
538
589
  channel.on("data", (chunk) => {
539
- out = appendCapped(out, chunk.toString("utf8"));
540
- if (pgid !== void 0 || !markerPending) return;
590
+ const text = outDecoder.write(chunk);
591
+ out = appendCapped(out, text);
592
+ if (pgid !== void 0 || !markerPending) {
593
+ forward("out", text);
594
+ return;
595
+ }
596
+ pendingForward += text;
541
597
  const marker = takePgid(out);
542
598
  if (marker.pgid !== void 0) {
543
599
  pgid = marker.pgid;
544
600
  out = marker.out;
545
601
  markerPending = false;
546
602
  if (timedOut) terminateGroup();
547
- } else if (out.includes("\n")) markerPending = false;
603
+ if (cancelled) terminateGroup();
604
+ const newline = pendingForward.indexOf("\n");
605
+ const visible = newline < 0 ? "" : pendingForward.slice(newline + 1);
606
+ pendingForward = "";
607
+ forward("out", visible);
608
+ return;
609
+ }
610
+ if (out.includes("\n") || pendingForward.length > 8192) {
611
+ markerPending = false;
612
+ const visible = pendingForward;
613
+ pendingForward = "";
614
+ forward("out", visible);
615
+ }
548
616
  });
549
617
  channel.stderr.on("data", (chunk) => {
550
- err = appendCapped(err, chunk.toString("utf8"));
618
+ const text = errDecoder.write(chunk);
619
+ err = appendCapped(err, text);
620
+ forward("err", text);
551
621
  });
552
622
  });
553
623
  });
@@ -1062,6 +1132,8 @@ const REMOTE_API = {
1062
1132
  test: "/api/dsh-remote/test",
1063
1133
  keygen: "/api/dsh-remote/keygen",
1064
1134
  exec: "/api/dsh-remote/exec",
1135
+ /** Streaming exec (server-sent events) for the long network verbs. */
1136
+ execStream: "/api/dsh-remote/exec-stream",
1065
1137
  files: "/api/dsh-remote/files",
1066
1138
  /** Streaming binary upload: raw request body, host and path in the query. */
1067
1139
  upload: "/api/dsh-remote/upload",
@@ -1551,8 +1623,14 @@ var TerminalSessionRegistry = class {
1551
1623
  //#region src/timeouts.ts
1552
1624
  /** Smallest accepted timeout: below this, local work would start failing. */
1553
1625
  const MIN_TIMEOUT_MS = 1e3;
1554
- /** Largest accepted timeout — the exec route clamps to the same ceiling. */
1555
- const MAX_TIMEOUT_MS = 6e5;
1626
+ /**
1627
+ * Largest accepted timeout — the exec route clamps to the same ceiling.
1628
+ *
1629
+ * Raised from 10 to 30 minutes when the SCM push gained a live output panel:
1630
+ * a Gerrit push on a large repo waits for server-side hooks and can outlast
1631
+ * the old ceiling with nothing wrong on either side.
1632
+ */
1633
+ const MAX_TIMEOUT_MS = 18e5;
1556
1634
  /**
1557
1635
  * Default for one git command (status/log/branches/diff over exec).
1558
1636
  *
@@ -1580,7 +1658,7 @@ function clampTimeout(value, fallback) {
1580
1658
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1581
1659
  const whole = Math.floor(value);
1582
1660
  if (whole < 1e3) return MIN_TIMEOUT_MS;
1583
- if (whole > 6e5) return MAX_TIMEOUT_MS;
1661
+ if (whole > 18e5) return MAX_TIMEOUT_MS;
1584
1662
  return whole;
1585
1663
  }
1586
1664
  /**
@@ -1843,6 +1921,130 @@ function makeRoutes(deps) {
1843
1921
  writeJson(res, 200, await pool.exec(body.alias, body.command, timeoutMs === void 0 ? {} : { timeoutMs }));
1844
1922
  }
1845
1923
  },
1924
+ {
1925
+ kind: "exact",
1926
+ path: REMOTE_API.execStream,
1927
+ handler: async (req, res) => {
1928
+ if (!isLoopbackRequest(req)) {
1929
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1930
+ return;
1931
+ }
1932
+ if (req.method !== "POST") {
1933
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1934
+ return;
1935
+ }
1936
+ const body = await readJsonBody(req, { objectOnly: true });
1937
+ if (body === null || typeof body.alias !== "string" || typeof body.command !== "string" || body.command === "") {
1938
+ writeJson(res, 400, { error: "alias and a non-empty command are required" });
1939
+ return;
1940
+ }
1941
+ const timeoutMs = clampExecTimeout(body.timeoutMs);
1942
+ res.writeHead(200, {
1943
+ "Content-Type": "text/event-stream; charset=utf-8",
1944
+ "Cache-Control": "no-cache, no-transform",
1945
+ Connection: "keep-alive",
1946
+ "X-Accel-Buffering": "no"
1947
+ });
1948
+ /** No more events to the client (finished, or the socket broke). */
1949
+ let outputClosed = false;
1950
+ /** `res.end` already called. */
1951
+ let ended = false;
1952
+ /** The client asked to stop: kill the remote even if the read is still opening. */
1953
+ let cancelRequested = false;
1954
+ /** The cancel handle was invoked (only once). */
1955
+ let cancelSent = false;
1956
+ let cancel;
1957
+ /** Server-side forward cap: the client shows a scrolling tail anyway. */
1958
+ let sent = 0;
1959
+ const FORWARD_CAP = 4 * 1024 * 1024;
1960
+ /**
1961
+ * Stop the remote process group exactly once.
1962
+ *
1963
+ * The disconnect may happen while `pool.exec` is still acquiring the
1964
+ * channel (slot queue, setsid probe, connect) — the handle does not exist
1965
+ * yet then, so the request is latched and fired the moment `onCancel`
1966
+ * arrives; otherwise a push would finish unseen and uncancellable.
1967
+ */
1968
+ const abortRemote = () => {
1969
+ cancelRequested = true;
1970
+ if (cancel === void 0 || cancelSent) return;
1971
+ cancelSent = true;
1972
+ cancel();
1973
+ };
1974
+ const send = (event) => {
1975
+ if (outputClosed) return;
1976
+ try {
1977
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
1978
+ } catch {
1979
+ outputClosed = true;
1980
+ abortRemote();
1981
+ }
1982
+ };
1983
+ const heartbeat = setInterval(() => {
1984
+ if (outputClosed) return;
1985
+ try {
1986
+ res.write(": ping\n\n");
1987
+ } catch {
1988
+ outputClosed = true;
1989
+ abortRemote();
1990
+ }
1991
+ }, 15e3);
1992
+ heartbeat.unref?.();
1993
+ const stop = () => {
1994
+ if (ended) return;
1995
+ ended = true;
1996
+ outputClosed = true;
1997
+ clearInterval(heartbeat);
1998
+ try {
1999
+ res.end();
2000
+ } catch {}
2001
+ };
2002
+ res.on("close", () => {
2003
+ abortRemote();
2004
+ stop();
2005
+ });
2006
+ try {
2007
+ const result = await pool.exec(body.alias, body.command, {
2008
+ ...timeoutMs === void 0 ? {} : { timeoutMs },
2009
+ onData: (stream, text) => {
2010
+ if (outputClosed || sent >= FORWARD_CAP) return;
2011
+ sent += Buffer.byteLength(text, "utf8");
2012
+ send({
2013
+ t: stream,
2014
+ d: text
2015
+ });
2016
+ },
2017
+ onCancel: (handle) => {
2018
+ cancel = handle;
2019
+ if (cancelRequested && !cancelSent) {
2020
+ cancelSent = true;
2021
+ handle();
2022
+ }
2023
+ }
2024
+ });
2025
+ if (outputClosed) return;
2026
+ send({
2027
+ t: "exit",
2028
+ success: result.success,
2029
+ exitCode: result.exitCode,
2030
+ timedOut: result.timedOut,
2031
+ durationMs: result.durationMs,
2032
+ ...result.error === void 0 ? {} : { error: result.error }
2033
+ });
2034
+ } catch (error) {
2035
+ send({
2036
+ t: "exit",
2037
+ success: false,
2038
+ exitCode: null,
2039
+ timedOut: false,
2040
+ durationMs: 0,
2041
+ error: error instanceof Error ? error.message : String(error)
2042
+ });
2043
+ } finally {
2044
+ stop();
2045
+ }
2046
+ }
2047
+ },
1846
2048
  {
1847
2049
  kind: "exact",
1848
2050
  path: REMOTE_API.files,
@@ -4006,7 +4208,7 @@ const Config = z.object({
4006
4208
  "off",
4007
4209
  "shadow",
4008
4210
  "enforce"
4009
- ]).default("off").volatile(),
4211
+ ]).default("enforce").volatile(),
4010
4212
  gitTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_GIT_TIMEOUT_MS).volatile(),
4011
4213
  readTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_READ_TIMEOUT_MS).volatile()
4012
4214
  });
@@ -4063,7 +4265,7 @@ function apply(ctx, config) {
4063
4265
  const worlds = new RemoteWorlds(ctx, {
4064
4266
  store,
4065
4267
  pool,
4066
- mode: () => readField(config?.routingMode, "off")
4268
+ mode: () => readField(config?.routingMode, "enforce")
4067
4269
  });
4068
4270
  ctx.provide("remoteWorlds", worlds);
4069
4271
  ctx.effect(() => () => {
@@ -100,6 +100,18 @@ export declare const api: {
100
100
  }>;
101
101
  };
102
102
  exec: (alias: string, command: string, timeoutMs?: number) => Promise<RemoteExecResult>;
103
+ /**
104
+ * Streaming exec: the network verbs' live log.
105
+ *
106
+ * The route answers as SSE frames; `onData` receives decoded text while the
107
+ * command runs, and the promise settles on the `exit` frame. An aborted
108
+ * `signal` cancels the remote process group on the host side (the route acts
109
+ * on the closed response), so a cancel button can actually stop a push.
110
+ */
111
+ execStream: (alias: string, command: string, timeoutMs: number, handlers: {
112
+ onData?: (stream: "out" | "err", text: string) => void;
113
+ signal?: AbortSignal;
114
+ }) => Promise<RemoteExecResult>;
103
115
  getWorkspaces: () => Promise<{
104
116
  workspaces: RemoteWorkspaceBinding[];
105
117
  } & RemoteRoutingStatus & RemotePluginMeta>;
@@ -95,7 +95,11 @@ interface InputSnapshot {
95
95
  draft: string;
96
96
  draftRev?: number;
97
97
  occurrences?: readonly OccurrenceLike[];
98
- phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting';
98
+ /**
99
+ * Host submission plane. `sending` exists since DSH 0.2.2-alpha.2: an ordinary
100
+ * message the host has not accepted yet — the editor is read-only there too.
101
+ */
102
+ phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending';
99
103
  claim?: {
100
104
  readonly token: string;
101
105
  };
@@ -0,0 +1,58 @@
1
+ /** What a streamed push should do: remembered refspec ('' = plain) + remote. */
2
+ export interface PushTarget {
3
+ refspec: string;
4
+ remote: string;
5
+ }
6
+ /**
7
+ * The commands one network verb runs, in order.
8
+ *
9
+ * Pure so the gate can pin the sequence: sync is pull-then-push (the historical
10
+ * order), and a push with a refspec must not fall back to the bare form.
11
+ */
12
+ export declare function networkCommands(root: string, kind: 'fetch' | 'pull' | 'push' | 'sync', push: PushTarget): string[];
13
+ /** Live-log cap: keep the TAIL, because the head of a long push is never the point. */
14
+ export declare const EXEC_OUTPUT_CAP = 200000;
15
+ /**
16
+ * Fold carriage-return progress rewrites for DISPLAY.
17
+ *
18
+ * git's progress lines are written as `Counting: 1%\rCounting: 2%\r…` and a
19
+ * `<pre>` would render every rewrite as its own line (hundreds of them for one
20
+ * push). Only the content after the last `\r` of a line survives.
21
+ *
22
+ * Fold at RENDER time, over the complete accumulated text — not inside
23
+ * `appendExecOutput`. A chunk can end mid-rewrite (git writes bare `\r`s at
24
+ * arbitrary boundaries), and folding a fragment loses the state that the next
25
+ * fragment continues: measured against real `git push --progress` stderr, the
26
+ * per-chunk fold glued `Counting: 1%Counting: 2%…` into one long line.
27
+ */
28
+ export declare function foldExecOutput(text: string): string;
29
+ /**
30
+ * Append one decoded chunk, keeping the TAIL.
31
+ *
32
+ * The raw `\r` bytes are preserved on purpose: the view folds them once, over
33
+ * the whole accumulated log, so a rewrite split across two chunks still folds
34
+ * correctly (`foldExecOutput`).
35
+ */
36
+ export declare function appendExecOutput(previous: string, chunk: string, cap?: number): string;
37
+ /**
38
+ * Split an SSE buffer into complete frames and the incomplete tail.
39
+ *
40
+ * A chunk can end in the middle of `data: {...}` or between the two newlines
41
+ * that terminate a frame, so the caller must carry `rest` into the next read.
42
+ */
43
+ export declare function takeSseFrames(buffer: string): {
44
+ frames: string[];
45
+ rest: string;
46
+ };
47
+ /**
48
+ * One SSE frame's JSON payload; `undefined` for comments (heartbeats), empty
49
+ * frames and malformed JSON — a broken heartbeat must not kill the stream.
50
+ */
51
+ export declare function parseSseFrame(frame: string): unknown | undefined;
52
+ /** `m:ss` (or `h:mm:ss`) for the running timer. */
53
+ export declare function formatElapsed(ms: number): string;
54
+ /**
55
+ * The network verbs' budget: at least 20 minutes, or twice the configured git
56
+ * budget when that is larger, never past the host's 30-minute ceiling.
57
+ */
58
+ export declare function networkBudgetMs(gitTimeoutMs: number): number;
@@ -11,7 +11,7 @@
11
11
  * them here keeps the check meaningful in both directions: every entry must
12
12
  * exist in both dictionaries, and nothing else may be unreferenced.
13
13
  */
14
- export declare const DYNAMIC_KEYS: readonly ["common.loading", "editor.saving", "editor.unsaved", "editor.saved", "scm.hunkStaged", "scm.hunkUnstaged", "scm.hunkReverted"];
14
+ export declare const DYNAMIC_KEYS: readonly ["common.loading", "editor.saving", "editor.unsaved", "editor.saved", "scm.hunkStaged", "scm.hunkUnstaged", "scm.hunkReverted", "history.details", "history.content", "history.diff"];
15
15
  export type Lang = 'zh' | 'en';
16
16
  export declare function detectLang(): Lang;
17
17
  export declare function makeT(lang: Lang): (key: string) => string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The history panel's commit pane: the selected commit in the three views the
3
+ * DSH-better-sidebar changes pane exposes — `提交详情` (the card), `内容` (the
4
+ * files the commit touched) and `差异` (its patch).
5
+ *
6
+ * Deliberately better than the reference pane in the places a narrow sidebar
7
+ * hurts most:
8
+ * - a sticky patch toolbar (previous/next file, current file, wrap toggle,
9
+ * copy) so a long diff never loses the reader's position or the controls;
10
+ * - sticky file headers per block, because "which file am I looking at" was
11
+ * the reference's biggest scroll cost;
12
+ * - `内容` groups files by directory with collapsible folders and a path
13
+ * filter, instead of a flat wall of paths;
14
+ * - right-click opens a real menu on every changed file (open in the editor,
15
+ * show in the diff, copy the path / the file patch) — the editor's own
16
+ * gesture, not a second mental model;
17
+ * - expand/restore the pane to full height, and copy the commit message,
18
+ * without leaving the panel.
19
+ *
20
+ * Frameless by design: the split, the frame and the pane height belong to the
21
+ * history panel, so this fills whatever box it is handed and scrolls itself.
22
+ * Read-only: a commit's patch is history, so the only mutations are "check this
23
+ * commit out" (confirmed) and copying — staging/reverting lives on worktree
24
+ * diffs.
25
+ *
26
+ * The patch is loaded lazily: a reader of 提交详情 must not pay a `git show
27
+ * --patch` round trip per clicked commit, and the pane asks for it only while
28
+ * the 差异 tab is on screen.
29
+ */
30
+ import { type ReactNode } from 'react';
31
+ import type { GitCommit, GitCommitFile } from '../../protocol.ts';
32
+ import type { Lang } from '../i18n.ts';
33
+ import { type HistoryTab } from './commit-model.ts';
34
+ /** Translator shape shared by every view (`makeT` returns the same). */
35
+ type Translate = (key: string) => string;
36
+ export interface CommitPaneProps {
37
+ commit: GitCommit;
38
+ tab: HistoryTab;
39
+ onTab: (next: HistoryTab) => void;
40
+ onClose: () => void;
41
+ /** True while the pane covers the whole panel instead of its usual split. */
42
+ expanded: boolean;
43
+ onToggleExpand: () => void;
44
+ /** Message text below the subject line ('' = none, or the read failed). */
45
+ body: string;
46
+ /** `git show --name-status` rows; undefined while the read is in flight. */
47
+ files: GitCommitFile[] | undefined;
48
+ /** Set when the file-list read failed (the 内容 tab shows it). */
49
+ filesError: string | undefined;
50
+ /** Raw `git show --patch` text; undefined until the 差异 tab asks for it. */
51
+ patch: string | undefined;
52
+ /** The remote read hit its byte cap: the parsed prefix is NOT the whole diff. */
53
+ patchTruncated: boolean;
54
+ patchLoading: boolean;
55
+ patchError: string | undefined;
56
+ /** A file selected in 内容 (or a patch header), highlighted in 差异. */
57
+ focusPath: string | undefined;
58
+ onFocusPath: (path: string) => void;
59
+ /** `origPath` is a rename source: the editor drill-down must diff BOTH paths. */
60
+ onOpenFile: (path: string, origPath?: string) => void;
61
+ /** Reveal a changed file's containing directory in the explorer. */
62
+ onRevealFile: (path: string) => void;
63
+ /** Open a terminal tab already `cd`d into the file's directory. */
64
+ onOpenTerminal: (path: string) => void;
65
+ /** Remote ABSOLUTE path of a repo-relative commit path (copy-absolute). */
66
+ absPathOf: (path: string) => string;
67
+ onCheckout: () => void;
68
+ onReload: () => void;
69
+ t: Translate;
70
+ lang: Lang;
71
+ }
72
+ export declare function CommitPane(props: CommitPaneProps): ReactNode;
73
+ export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Live output panel for the long network verbs (fetch / pull / push / sync).
3
+ *
4
+ * The unary exec route can only answer after the fact, so a 20-minute Gerrit
5
+ * push used to look like a frozen button. This panel is fed by the streaming
6
+ * exec route: it shows the git/remote output while it runs, a running clock,
7
+ * a stop button that really kills the remote process group, and keeps the full
8
+ * (tail-capped) log for copying after the fact.
9
+ *
10
+ * Rendered through a PORTAL on `document.body`: the SCM panel lives inside the
11
+ * sidebar's keep-alive slot, which the host hides with `display:none` when
12
+ * another rail view is picked — and a `position: fixed` descendant of a hidden
13
+ * ancestor disappears with it, taking the only cancel affordance of a running
14
+ * push along. The portal keeps the panel (and its stop button) reachable.
15
+ */
16
+ import { type ReactNode } from 'react';
17
+ export type ExecPanelStatus = 'running' | 'done' | 'error' | 'cancelled';
18
+ export interface ExecPanelState {
19
+ title: string;
20
+ output: string;
21
+ status: ExecPanelStatus;
22
+ /** Wall-clock start, so the timer survives re-renders. */
23
+ startedAt: number;
24
+ elapsedMs: number;
25
+ /** One-line footer: exit code / duration / error. */
26
+ exitText?: string;
27
+ }
28
+ export interface ExecPanelProps {
29
+ state: ExecPanelState;
30
+ onCancel: () => void;
31
+ onClose: () => void;
32
+ t: (key: string) => string;
33
+ }
34
+ export declare function ExecPanel(props: ExecPanelProps): ReactNode;
@@ -1,17 +1,25 @@
1
1
  /**
2
2
  * History view: the commit graph with branch lanes and merge curves (lane
3
- * layout ported from the previous Git view), plus click-to-expand changed
4
- * files and a per-file diff of that commit.
3
+ * layout ported from the previous Git view), plus a COMMIT PANE below it —
4
+ * the selected commit's 提交详情 / 内容 / 差异 tabs (see `./CommitPane.tsx`).
5
+ *
6
+ * The pane replaced the old per-row inline expansion: a commit is a subject
7
+ * with three answers, not a disclosure widget, and the pane keeps the graph
8
+ * readable while one commit is open. Clicking a row selects (the pane opens on
9
+ * 提交详情), clicking a changed file shows its patch in 差异, and a commit's
10
+ * per-file two-sided diff still opens in the editor through `openCommitFile`.
5
11
  *
6
12
  * Loading is a SINGLE capped call (`api.git.log(..., GIT_LOG_MAX)`); a deep
7
13
  * repository shows the newest GIT_LOG_MAX commits and is filtered client-side.
8
- * The docstring here used to promise scroll-triggered paging that does not
9
- * exist, which is worse than no comment: a reader trusts it.
10
14
  *
11
15
  * Each repository's last answer is kept in memory (`./panel-cache.ts`), so a
12
16
  * switch back to a workspace repaints its commit list at once and the log is
13
17
  * re-read behind it — the tab used to blank on every switch, and worse, kept the
14
18
  * PREVIOUS repository's rows until the new log arrived.
19
+ *
20
+ * Per-commit reads are cached separately (message + files, and the lazy patch),
21
+ * keyed by repository AND hash, so switching repositories can never show one
22
+ * host's commit body under another host's graph.
15
23
  */
16
24
  import { type ReactNode } from 'react';
17
25
  export declare function HistoryPanel(): ReactNode;
@@ -8,7 +8,8 @@
8
8
  * messages a user can act on (missing identity, no upstream, conflicts, ...).
9
9
  */
10
10
  import { REMOTE_API } from '../../protocol.ts';
11
- import type { GitBranch, GitCommitFile, GitRemote, GitStash, GitStatusDetail, RemoteRead } from '../../protocol.ts';
11
+ import type { GitBranch, GitCommitFile, GitRemote, GitStash, GitStatusDetail, RemoteExecResult, RemoteRead } from '../../protocol.ts';
12
+ import { type PushTarget } from '../exec-output.ts';
12
13
  /** Error carrying a message already fit for the UI. */
13
14
  export declare class GitOpError extends Error {
14
15
  }
@@ -90,8 +91,34 @@ export interface DiffPayload {
90
91
  export declare function loadDiff(alias: string, root: string, path: string, staged: boolean, untracked?: boolean): Promise<DiffPayload>;
91
92
  /** Files touched by one commit. */
92
93
  export declare function loadCommitFiles(alias: string, root: string, hash: string): Promise<GitCommitFile[]>;
93
- /** Diff of one path inside a commit (used by the history file drill-down). */
94
- export declare function loadCommitFileDiff(alias: string, root: string, hash: string, path: string): Promise<DiffPayload>;
94
+ /**
95
+ * Diff of one path inside a commit (the editor drill-down).
96
+ *
97
+ * `origPath` is the rename source: `git show -M -- <old> <new>` needs BOTH
98
+ * paths or git cannot pair them and a 100% rename renders as a fully-added
99
+ * file. A root commit has no `^` revision — `git show <hash>` already diffs
100
+ * against the empty tree, and the failed old-side read degrades to ''.
101
+ */
102
+ export declare function loadCommitFileDiff(alias: string, root: string, hash: string, path: string, origPath?: string): Promise<DiffPayload>;
103
+ /** Raw message of one commit, split into its subject line and the body. */
104
+ export declare function loadCommitMessage(alias: string, root: string, hash: string): Promise<{
105
+ subject: string;
106
+ body: string;
107
+ }>;
108
+ /**
109
+ * Whole patch of one commit (the commit header is stripped: starts at
110
+ * `diff --git`).
111
+ *
112
+ * `truncated` answers whether the read hit `commitPatchCommand`'s BYTE cap.
113
+ * The cap is applied with `head -c` (bytes) while the string length counts
114
+ * UTF-16 code units, so the comparison must measure BYTES too — a CJK-heavy
115
+ * patch capped at 2,000,000 bytes is only ~667k characters, and a char-length
116
+ * check silently reported "complete" for a cut patch.
117
+ */
118
+ export declare function loadCommitPatch(alias: string, root: string, hash: string): Promise<{
119
+ text: string;
120
+ truncated: boolean;
121
+ }>;
95
122
  /** Local + remote-tracking branches. */
96
123
  export declare function loadBranches(alias: string, root: string, current: string): Promise<GitBranch[]>;
97
124
  /** Configured remotes. */
@@ -120,6 +147,26 @@ export declare function commitAll(alias: string, root: string, message: string,
120
147
  export declare function lastCommitMessage(alias: string, root: string): Promise<string>;
121
148
  /** fetch / pull / push / sync. */
122
149
  export declare function syncRepo(alias: string, root: string, kind: 'fetch' | 'pull' | 'push' | 'sync'): Promise<string>;
150
+ /**
151
+ * Push to an explicit remote with an explicit refspec (Gerrit review flow:
152
+ * `HEAD:refs/for/<branch>`). Same timeout budget as the other network verbs.
153
+ */
154
+ export declare function pushWithRefspec(alias: string, root: string, remote: string, refspec: string): Promise<string>;
155
+ export interface NetworkStreamHandlers {
156
+ onData?: (stream: 'out' | 'err', text: string) => void;
157
+ signal?: AbortSignal;
158
+ }
159
+ /**
160
+ * Run one network verb over the STREAMING exec route and accumulate its log.
161
+ *
162
+ * Stops at the first failing step (a sync whose pull failed must not push a
163
+ * half-merged tree). The caller owns the live panel; this returns only once the
164
+ * whole verb has settled, with `output` capped to its tail.
165
+ */
166
+ export declare function streamNetworkVerb(alias: string, root: string, kind: 'fetch' | 'pull' | 'push' | 'sync', push: PushTarget, handlers?: NetworkStreamHandlers): Promise<{
167
+ result: RemoteExecResult;
168
+ output: string;
169
+ }>;
123
170
  /** Branch verbs. */
124
171
  export declare function branchOp(alias: string, root: string, op: {
125
172
  kind: 'checkout';