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
@@ -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. */
@@ -39,6 +39,11 @@ export interface Config {
39
39
  /**
40
40
  * P2 transparent-routing kill-switch: off = pure local passthrough;
41
41
  * shadow = log remote decisions but execute locally; enforce = route.
42
+ *
43
+ * The DEFAULT is `enforce`: the plugin exists to execute remote workspaces
44
+ * remotely, and a fresh profile that silently kept executing locally was the
45
+ * most common "why is nothing remote" report. `off`/`shadow` stay available as
46
+ * explicit opt-outs for debugging and for hosts that must stay local.
42
47
  */
43
48
  routingMode?: RoutingMode;
44
49
  /** Per-command timeout for git work on a remote host (ms). */
@@ -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.9.3",
4
+ "version": "0.11.0",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -59,7 +59,7 @@
59
59
  "./locale/*.json": "./locale/*.json"
60
60
  },
61
61
  "engines": {
62
- "dsh": ">=0.2.1-alpha.3 <0.3.0"
62
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
63
63
  },
64
64
  "dsh": {
65
65
  "bundle": {
@@ -122,7 +122,7 @@
122
122
  "@codemirror/view": "^6.43.14",
123
123
  "@deepseek-ai/dsh-fs-ssh": "0.2.1-rc.2",
124
124
  "@deepseek-ai/dsh-ssh": "0.2.1-rc.2",
125
- "@deepseek-ai/schemastery": "3.18.5",
125
+ "@deepseek-ai/schemastery": "3.18.6-alpha.1",
126
126
  "@lezer/highlight": "1.2.3",
127
127
  "@xterm/addon-fit": "^0.11.0",
128
128
  "@xterm/xterm": "^6.0.0",
@@ -130,44 +130,44 @@
130
130
  "zod": "4.4.3"
131
131
  },
132
132
  "peerDependencies": {
133
- "@deepseek-ai/cordis": "^4.0.5-alpha.2",
134
- "@deepseek-ai/dsh-agent-preset": "^0.2.1-rc.2",
135
- "@deepseek-ai/dsh-bash-local": "^0.2.1-rc.2",
136
- "@deepseek-ai/dsh-file-reference": "^0.2.1-rc.2",
137
- "@deepseek-ai/dsh-fs": "^0.2.1-rc.2",
138
- "@deepseek-ai/dsh-shell": "^0.2.1-rc.2",
139
- "@deepseek-ai/dsh-spill": "^0.2.1-rc.2",
140
- "@deepseek-ai/dsh-subprocess": "^0.2.1-rc.2",
141
- "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
133
+ "@deepseek-ai/cordis": "^4.0.6-alpha.1",
134
+ "@deepseek-ai/dsh-agent-preset": "^0.2.2-alpha.2",
135
+ "@deepseek-ai/dsh-bash-local": "^0.2.2-alpha.2",
136
+ "@deepseek-ai/dsh-file-reference": "^0.2.2-alpha.2",
137
+ "@deepseek-ai/dsh-fs": "^0.2.2-alpha.2",
138
+ "@deepseek-ai/dsh-shell": "^0.2.2-alpha.2",
139
+ "@deepseek-ai/dsh-spill": "^0.2.2-alpha.2",
140
+ "@deepseek-ai/dsh-subprocess": "^0.2.2-alpha.2",
141
+ "@deepseek-ai/dsh-tools": "^0.2.2-alpha.2",
142
142
  "react": "^18.2.0",
143
143
  "react-dom": "^18.2.0"
144
144
  },
145
145
  "devDependencies": {
146
- "@deepseek-ai/cordis": "^4.0.4",
147
- "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
148
- "@deepseek-ai/dsh-agent-preset-registry": "0.2.1-rc.2",
149
- "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.2",
150
- "@deepseek-ai/dsh-bash-local": "0.2.1-rc.2",
151
- "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
152
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
153
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.2",
154
- "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.1-rc.2",
155
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
156
- "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.2",
157
- "@deepseek-ai/dsh-file-reference": "0.2.1-rc.2",
158
- "@deepseek-ai/dsh-fs": "0.2.1-rc.2",
159
- "@deepseek-ai/dsh-host-directory-picker": "0.2.1-rc.2",
160
- "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
161
- "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
162
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
163
- "@deepseek-ai/dsh-session": "0.2.1-rc.2",
164
- "@deepseek-ai/dsh-shell": "0.2.1-rc.2",
165
- "@deepseek-ai/dsh-spill": "0.2.1-rc.2",
166
- "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
167
- "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
168
- "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
169
- "@deepseek-ai/dsh-working-directory": "0.2.1-rc.2",
170
- "@deepseek-ai/dsh-workspace": "0.2.1-rc.2",
146
+ "@deepseek-ai/cordis": "^4.0.6-alpha.1",
147
+ "@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
148
+ "@deepseek-ai/dsh-agent-preset-registry": "0.2.2-alpha.2",
149
+ "@deepseek-ai/dsh-api-remotes": "0.2.2-alpha.2",
150
+ "@deepseek-ai/dsh-bash-local": "0.2.2-alpha.2",
151
+ "@deepseek-ai/dsh-client-locale": "0.2.2-alpha.2",
152
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.2-alpha.2",
153
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.2-alpha.2",
154
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.2-alpha.2",
155
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.2-alpha.2",
156
+ "@deepseek-ai/dsh-client-ui-workspace": "0.2.2-alpha.2",
157
+ "@deepseek-ai/dsh-file-reference": "0.2.2-alpha.2",
158
+ "@deepseek-ai/dsh-fs": "0.2.2-alpha.2",
159
+ "@deepseek-ai/dsh-host-directory-picker": "0.2.2-alpha.2",
160
+ "@deepseek-ai/dsh-host-webserver": "0.2.2-alpha.2",
161
+ "@deepseek-ai/dsh-llm": "0.2.2-alpha.2",
162
+ "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
163
+ "@deepseek-ai/dsh-session": "0.2.2-alpha.2",
164
+ "@deepseek-ai/dsh-shell": "0.2.2-alpha.2",
165
+ "@deepseek-ai/dsh-spill": "0.2.2-alpha.2",
166
+ "@deepseek-ai/dsh-subprocess": "0.2.2-alpha.2",
167
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.2",
168
+ "@deepseek-ai/dsh-tools": "0.2.2-alpha.2",
169
+ "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.2",
170
+ "@deepseek-ai/dsh-workspace": "0.2.2-alpha.2",
171
171
  "@types/node": "^22.20.0",
172
172
  "@types/react": "~18.3.1",
173
173
  "@types/react-dom": "^18.3.5",
@@ -22,7 +22,8 @@ import { Notice, Row, hairline, optionStyle, selectStyle } from './ui.tsx'
22
22
  export function SettingsHostsSection(_props: SettingsSectionOwnerProps): ReactNode {
23
23
  const [t] = useState(() => makeT(detectLang()))
24
24
  const [hosts, setHosts] = useState<RemoteHostSummary[]>([])
25
- const [mode, setMode] = useState<'off' | 'shadow' | 'enforce'>('off')
25
+ /** Pre-load placeholder; matches the plugin's default (nforce) until the host answers. */
26
+ const [mode, setMode] = useState<'off' | 'shadow' | 'enforce'>('enforce')
26
27
  const [version, setVersion] = useState('')
27
28
  const [saving, setSaving] = useState(false)
28
29
  const [error, setError] = useState<string | undefined>(undefined)
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 }) =>
@@ -100,7 +100,11 @@ interface InputSnapshot {
100
100
  draft: string
101
101
  draftRev?: number
102
102
  occurrences?: readonly OccurrenceLike[]
103
- phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
103
+ /**
104
+ * Host submission plane. `sending` exists since DSH 0.2.2-alpha.2: an ordinary
105
+ * message the host has not accepted yet — the editor is read-only there too.
106
+ */
107
+ phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
104
108
  claim?: { readonly token: string }
105
109
  }
106
110
 
@@ -295,7 +299,7 @@ export function insertDraftReference(
295
299
  const { actx, input } = resolved
296
300
  const snapshot = input.state.getSnapshot()
297
301
  if (input.insertReference === undefined || snapshot.draftRev === undefined) return false
298
- if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting') return false
302
+ if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting' || snapshot.phase === 'sending') return false
299
303
  before = snapshot.draftRev
300
304
  const at = appendOffset(snapshot)
301
305
  if (at === undefined) return false
@@ -346,6 +350,11 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
346
350
  const { actx, input } = resolved
347
351
  const snapshot = input.state.getSnapshot()
348
352
  const draft = snapshot.draft
353
+ // The host makes the editor read-only on every submission plane: a slash
354
+ // command being checked, a claimed command running, and (since DSH
355
+ // 0.2.2-alpha.2) an ordinary message waiting for the host to accept it.
356
+ // Writing there would either be refused or land after the send snapshot.
357
+ if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting' || snapshot.phase === 'sending') return false
349
358
  if ((snapshot.occurrences ?? []).length > 0 || snapshot.phase === 'claimed') {
350
359
  const at = appendOffset(snapshot)
351
360
  if (snapshot.draftRev === undefined || at === undefined) {