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.
- package/README.en.md +13 -10
- package/README.md +14 -9
- package/cordis.patch.yml +10 -5
- package/lib/client.js +3319 -1093
- package/lib/index.js +211 -9
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/composer.d.ts +5 -1
- package/lib/types/client/exec-output.d.ts +58 -0
- package/lib/types/client/i18n.d.ts +1 -1
- package/lib/types/client/scm/CommitPane.d.ts +73 -0
- package/lib/types/client/scm/ExecPanel.d.ts +34 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
- package/lib/types/client/scm/actions.d.ts +50 -3
- package/lib/types/client/scm/commit-model.d.ts +119 -0
- package/lib/types/client/store.d.ts +9 -3
- package/lib/types/client/vsc/kit.d.ts +16 -0
- package/lib/types/git-format.d.ts +5 -0
- package/lib/types/git-ops.d.ts +57 -2
- package/lib/types/index.d.ts +5 -0
- package/lib/types/pool.d.ts +9 -0
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/timeouts.d.ts +17 -2
- package/package.json +37 -37
- package/src/client/SettingsHosts.tsx +2 -1
- package/src/client/api.ts +61 -0
- package/src/client/composer.ts +11 -2
- package/src/client/exec-output.ts +129 -0
- package/src/client/i18n.ts +133 -4
- package/src/client/scm/CommitPane.tsx +726 -0
- package/src/client/scm/ExecPanel.tsx +124 -0
- package/src/client/scm/HistoryPanel.tsx +483 -104
- package/src/client/scm/ScmPanel.tsx +247 -9
- package/src/client/scm/actions.ts +93 -10
- package/src/client/scm/commit-model.ts +317 -0
- package/src/client/store.tsx +13 -6
- package/src/client/vsc/kit.tsx +71 -0
- package/src/client/vsc/vsc.css +647 -0
- package/src/git-format.ts +7 -3
- package/src/git-ops.ts +83 -7
- package/src/index.ts +10 -2
- package/src/pool.ts +84 -4
- package/src/protocol.ts +14 -0
- package/src/routes.ts +135 -1
- 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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1555
|
-
|
|
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 >
|
|
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("
|
|
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, "
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
|
|
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';
|