@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.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.md +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
3
|
+
//
|
|
4
|
+
// SPDX-License-Identifier: MIT
|
|
5
|
+
// The Podman terminal tab's body: the container and shell pickers over the
|
|
6
|
+
// settings card's live snapshot, and one xterm screen bound to the host's
|
|
7
|
+
// terminal stream. The workspace is the Session's own — never chosen, never
|
|
8
|
+
// shown. Closing the tab only detaches; the host retains the shell, and
|
|
9
|
+
// reopening the same tab reattaches and replays a snapshot.
|
|
10
|
+
import { FitAddon } from "@xterm/addon-fit";
|
|
11
|
+
import { Terminal } from "@xterm/xterm";
|
|
12
|
+
import xtermCss from "@xterm/xterm/css/xterm.css";
|
|
13
|
+
import { Button } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
14
|
+
import { useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
15
|
+
import { fieldLabel, imageSelect } from "./container-card-styles.js";
|
|
16
|
+
import { containerOptions } from "./terminal-targets.js";
|
|
17
|
+
import { forgetTerminalTitle, publishTerminalTitle, } from "./terminal-titles.js";
|
|
18
|
+
import { base64ToBytes, bytesToBase64, fetchTerminalShells, fetchTerminalTarget, openTerminalStream, sendTerminalControl, } from "./terminal-transport.js";
|
|
19
|
+
// One TextEncoder serves every keystroke: the type is stateless for our use.
|
|
20
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
21
|
+
// The xterm stylesheet is imported as text (the build's `.css` loader) and
|
|
22
|
+
// injected once, under this attribute, so a plugin reload cannot duplicate it.
|
|
23
|
+
const XTERM_STYLE_ATTRIBUTE = "data-dsh-podman-xterm";
|
|
24
|
+
const XTERM_CLASS = "dsh-podman-xterm";
|
|
25
|
+
const XTERM_STYLES = `
|
|
26
|
+
.${XTERM_CLASS}, .${XTERM_CLASS} .xterm { height: 100%; }
|
|
27
|
+
.${XTERM_CLASS} .xterm-viewport { background: var(--dsw-alias-bg-layer-2); }
|
|
28
|
+
`;
|
|
29
|
+
let xtermStylesInstalled = false;
|
|
30
|
+
function installXtermStyles() {
|
|
31
|
+
if (xtermStylesInstalled || typeof document === "undefined")
|
|
32
|
+
return;
|
|
33
|
+
xtermStylesInstalled = true;
|
|
34
|
+
if (document.head.querySelector(`style[${XTERM_STYLE_ATTRIBUTE}]`) !== null) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const tag = document.createElement("style");
|
|
38
|
+
tag.setAttribute(XTERM_STYLE_ATTRIBUTE, "");
|
|
39
|
+
tag.textContent = `${xtermCss}\n${XTERM_STYLES}`;
|
|
40
|
+
document.head.appendChild(tag);
|
|
41
|
+
}
|
|
42
|
+
// The screen follows the app theme through its CSS variables, read once at
|
|
43
|
+
// mount (a live theme change is a future concern; the harness's own terminal
|
|
44
|
+
// re-reads them through the theme service).
|
|
45
|
+
function readTerminalTheme() {
|
|
46
|
+
const style = getComputedStyle(document.documentElement);
|
|
47
|
+
const read = (name, fallback) => {
|
|
48
|
+
const value = style.getPropertyValue(name).trim();
|
|
49
|
+
return value === "" ? fallback : value;
|
|
50
|
+
};
|
|
51
|
+
const foreground = read("--dsw-alias-label-primary", "#e6e8ee");
|
|
52
|
+
return {
|
|
53
|
+
background: read("--dsw-alias-bg-layer-2", "#17191d"),
|
|
54
|
+
foreground,
|
|
55
|
+
cursor: read("--dsw-alias-brand-primary", foreground),
|
|
56
|
+
selectionBackground: read("--dsw-alias-label-secondary", "rgba(126, 146, 184, 0.4)"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function ContainerField(props) {
|
|
60
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { style: fieldLabel, children: props.label }), _jsxs("select", { style: props.selectStyle ?? imageSelect, "aria-label": props.label, value: props.container ?? "", disabled: props.disabled || props.workspaceSlug === undefined, onChange: (event) => {
|
|
61
|
+
props.onContainer(event.target.value);
|
|
62
|
+
}, children: [_jsx("option", { value: "", children: props.defaultLabel }), containerOptions(props.containers, props.workspaceSlug).map((name) => (_jsx("option", { value: name, children: name }, name)))] })] }));
|
|
63
|
+
}
|
|
64
|
+
export function PodmanTerminal(props) {
|
|
65
|
+
const { t, sessionId } = props;
|
|
66
|
+
const info = props.useTabInfo();
|
|
67
|
+
// The host keys retention by session + tab, so the composite keeps the two
|
|
68
|
+
// unambiguous in one opaque query value.
|
|
69
|
+
const tabId = `${sessionId}:${info.tab.id}`;
|
|
70
|
+
const state = props.useContainerCard((snapshot) => snapshot);
|
|
71
|
+
const params = info.tab.navigation.params;
|
|
72
|
+
// The workspace comes from the host, which resolves it from the Session
|
|
73
|
+
// itself: the browser neither chooses nor guesses one. `failed` means the
|
|
74
|
+
// host cannot place this session in a workspace.
|
|
75
|
+
const [target, setTarget] = useState({ phase: "loading" });
|
|
76
|
+
const workspace = target.phase === "ready"
|
|
77
|
+
? target.target.workspace
|
|
78
|
+
: undefined;
|
|
79
|
+
const workspaceSlug = target.phase === "ready"
|
|
80
|
+
? target.target.workspaceSlug
|
|
81
|
+
: undefined;
|
|
82
|
+
const unknownWorkspace = target.phase === "failed";
|
|
83
|
+
const [container, setContainer] = useState(params?.container);
|
|
84
|
+
const [shell, setShell] = useState(params?.shell);
|
|
85
|
+
const [shells, setShells] = useState({ phase: "loading" });
|
|
86
|
+
const [status, setStatus] = useState({ kind: "idle" });
|
|
87
|
+
const [title, setTitle] = useState("");
|
|
88
|
+
const [attempt, setAttempt] = useState(0);
|
|
89
|
+
const node = useRef(null);
|
|
90
|
+
const terminal = useRef();
|
|
91
|
+
const stream = useRef();
|
|
92
|
+
const terminalId = useRef();
|
|
93
|
+
// False once the screen is torn down, so a late frame or an unmount cleanup
|
|
94
|
+
// never touches a disposed emulator and an unmount never closes the shell.
|
|
95
|
+
const alive = useRef(true);
|
|
96
|
+
const reopen = useRef(false);
|
|
97
|
+
// The card snapshot is the only source of containers, and the settings scope
|
|
98
|
+
// may not have loaded yet.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
props.reload();
|
|
101
|
+
}, [props.reload]);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
setTarget({ phase: "loading" });
|
|
105
|
+
void fetchTerminalTarget(sessionId, controller.signal).then((resolved) => {
|
|
106
|
+
if (!controller.signal.aborted) {
|
|
107
|
+
setTarget({ phase: "ready", target: resolved });
|
|
108
|
+
}
|
|
109
|
+
}, (error) => {
|
|
110
|
+
if (controller.signal.aborted)
|
|
111
|
+
return;
|
|
112
|
+
setTarget({
|
|
113
|
+
phase: "failed",
|
|
114
|
+
message: error instanceof Error ? error.message : String(error),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
return () => {
|
|
118
|
+
controller.abort();
|
|
119
|
+
};
|
|
120
|
+
}, [sessionId]);
|
|
121
|
+
// Default container: the empty value means the workspace's default container
|
|
122
|
+
// to the host. The workspace row carries that container's podman name, which
|
|
123
|
+
// the orchestrator rejects, so it is never used as a target.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (workspace === undefined || container !== undefined)
|
|
126
|
+
return;
|
|
127
|
+
setContainer("");
|
|
128
|
+
}, [workspace, container]);
|
|
129
|
+
// The shells a container really offers; refetched whenever the target moves.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (workspace === undefined || workspace === "" || container === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
setShells({ phase: "loading" });
|
|
136
|
+
void fetchTerminalShells(workspace, container, sessionId, controller.signal).then((list) => {
|
|
137
|
+
if (controller.signal.aborted)
|
|
138
|
+
return;
|
|
139
|
+
setShells({ phase: "ready", shells: list });
|
|
140
|
+
setShell((previous) => previous ?? list[0]?.path);
|
|
141
|
+
}, (error) => {
|
|
142
|
+
if (controller.signal.aborted)
|
|
143
|
+
return;
|
|
144
|
+
setShells({
|
|
145
|
+
phase: "failed",
|
|
146
|
+
message: error instanceof Error ? error.message : String(error),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
return () => {
|
|
150
|
+
controller.abort();
|
|
151
|
+
};
|
|
152
|
+
}, [workspace, container, sessionId]);
|
|
153
|
+
// The emulator outlives every connection; a reconnect reuses it.
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
const element = node.current;
|
|
156
|
+
if (element === null)
|
|
157
|
+
return;
|
|
158
|
+
alive.current = true;
|
|
159
|
+
installXtermStyles();
|
|
160
|
+
const term = new Terminal({
|
|
161
|
+
cursorBlink: true,
|
|
162
|
+
fontSize: 13,
|
|
163
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
164
|
+
scrollback: 5000,
|
|
165
|
+
theme: readTerminalTheme(),
|
|
166
|
+
});
|
|
167
|
+
const addon = new FitAddon();
|
|
168
|
+
term.loadAddon(addon);
|
|
169
|
+
term.open(element);
|
|
170
|
+
terminal.current = term;
|
|
171
|
+
const fit = () => {
|
|
172
|
+
if (element.clientWidth === 0 || element.clientHeight === 0)
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
addon.fit();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// The element lost its box between measure and resize; the next
|
|
179
|
+
// observer callback retries.
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
fit();
|
|
183
|
+
const input = term.onData((data) => {
|
|
184
|
+
const id = terminalId.current;
|
|
185
|
+
if (id === undefined)
|
|
186
|
+
return;
|
|
187
|
+
void sendTerminalControl({
|
|
188
|
+
terminalId: id,
|
|
189
|
+
kind: "input",
|
|
190
|
+
data: bytesToBase64(TEXT_ENCODER.encode(data)),
|
|
191
|
+
}).catch(() => {
|
|
192
|
+
// A dead stream reports itself through a frame or the stream's error.
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
const resize = term.onResize(({ cols, rows }) => {
|
|
196
|
+
const id = terminalId.current;
|
|
197
|
+
if (id === undefined)
|
|
198
|
+
return;
|
|
199
|
+
void sendTerminalControl({ terminalId: id, kind: "resize", cols, rows })
|
|
200
|
+
.catch(() => { });
|
|
201
|
+
});
|
|
202
|
+
const observer = new ResizeObserver(fit);
|
|
203
|
+
observer.observe(element);
|
|
204
|
+
return () => {
|
|
205
|
+
alive.current = false;
|
|
206
|
+
forgetTerminalTitle(tabId);
|
|
207
|
+
stream.current?.abort();
|
|
208
|
+
stream.current = undefined;
|
|
209
|
+
observer.disconnect();
|
|
210
|
+
input.dispose();
|
|
211
|
+
resize.dispose();
|
|
212
|
+
term.dispose();
|
|
213
|
+
terminal.current = undefined;
|
|
214
|
+
};
|
|
215
|
+
}, []);
|
|
216
|
+
// One attachment per target: a target change (or a reconnect) closes the old
|
|
217
|
+
// shell, while an unmount only aborts and leaves the shell retained.
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const term = terminal.current;
|
|
220
|
+
if (term === undefined)
|
|
221
|
+
return;
|
|
222
|
+
if (workspace === undefined || workspace === "" ||
|
|
223
|
+
container === undefined ||
|
|
224
|
+
shell === undefined || shell === "") {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const controller = new AbortController();
|
|
228
|
+
stream.current = controller;
|
|
229
|
+
const forceReopen = reopen.current;
|
|
230
|
+
reopen.current = false;
|
|
231
|
+
terminalId.current = undefined;
|
|
232
|
+
term.reset();
|
|
233
|
+
setStatus({ kind: "connecting" });
|
|
234
|
+
const onFrame = (frame) => {
|
|
235
|
+
if (!alive.current)
|
|
236
|
+
return;
|
|
237
|
+
switch (frame.type) {
|
|
238
|
+
case "ready":
|
|
239
|
+
terminalId.current = frame.terminalId;
|
|
240
|
+
if (frame.title !== "") {
|
|
241
|
+
setTitle(frame.title);
|
|
242
|
+
publishTerminalTitle(tabId, frame.title);
|
|
243
|
+
}
|
|
244
|
+
setStatus({ kind: "running" });
|
|
245
|
+
break;
|
|
246
|
+
case "snapshot":
|
|
247
|
+
term.write(frame.screen);
|
|
248
|
+
break;
|
|
249
|
+
case "data":
|
|
250
|
+
term.write(base64ToBytes(frame.data));
|
|
251
|
+
break;
|
|
252
|
+
case "title":
|
|
253
|
+
setTitle(frame.title);
|
|
254
|
+
publishTerminalTitle(tabId, frame.title);
|
|
255
|
+
break;
|
|
256
|
+
case "exit":
|
|
257
|
+
terminalId.current = undefined;
|
|
258
|
+
setStatus({ kind: "exited", code: frame.exitCode });
|
|
259
|
+
break;
|
|
260
|
+
case "error":
|
|
261
|
+
setStatus({ kind: "error", message: frame.message });
|
|
262
|
+
break;
|
|
263
|
+
case "detached":
|
|
264
|
+
setStatus({ kind: "detached" });
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
void openTerminalStream({
|
|
269
|
+
sessionId,
|
|
270
|
+
tabId,
|
|
271
|
+
workspace,
|
|
272
|
+
container,
|
|
273
|
+
shell,
|
|
274
|
+
cols: term.cols > 0 ? term.cols : 80,
|
|
275
|
+
rows: term.rows > 0 ? term.rows : 24,
|
|
276
|
+
...(forceReopen ? { reopen: true } : {}),
|
|
277
|
+
}, onFrame, controller.signal).catch((error) => {
|
|
278
|
+
if (controller.signal.aborted)
|
|
279
|
+
return;
|
|
280
|
+
setStatus({
|
|
281
|
+
kind: "error",
|
|
282
|
+
message: error instanceof Error ? error.message : String(error),
|
|
283
|
+
});
|
|
284
|
+
}).finally(() => {
|
|
285
|
+
if (stream.current === controller)
|
|
286
|
+
stream.current = undefined;
|
|
287
|
+
});
|
|
288
|
+
return () => {
|
|
289
|
+
controller.abort();
|
|
290
|
+
if (stream.current === controller)
|
|
291
|
+
stream.current = undefined;
|
|
292
|
+
if (!alive.current)
|
|
293
|
+
return;
|
|
294
|
+
const id = terminalId.current;
|
|
295
|
+
terminalId.current = undefined;
|
|
296
|
+
if (id !== undefined) {
|
|
297
|
+
void sendTerminalControl({ terminalId: id, kind: "close" }).catch(() => {
|
|
298
|
+
// The shell may already be gone; reconnecting still works.
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}, [sessionId, tabId, workspace, container, shell, attempt]);
|
|
303
|
+
// The terminal takes focus only while its tab is the visible one.
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (info.tab.visible)
|
|
306
|
+
terminal.current?.focus();
|
|
307
|
+
}, [info.tab.visible]);
|
|
308
|
+
const canConnect = workspace !== undefined && workspace !== "" &&
|
|
309
|
+
shell !== undefined && shell !== "";
|
|
310
|
+
const connect = (forceReopen) => {
|
|
311
|
+
const id = terminalId.current;
|
|
312
|
+
stream.current?.abort();
|
|
313
|
+
stream.current = undefined;
|
|
314
|
+
terminalId.current = undefined;
|
|
315
|
+
if (id !== undefined) {
|
|
316
|
+
void sendTerminalControl({ terminalId: id, kind: "close" }).catch(() => { });
|
|
317
|
+
}
|
|
318
|
+
terminal.current?.reset();
|
|
319
|
+
reopen.current = forceReopen;
|
|
320
|
+
setAttempt((value) => value + 1);
|
|
321
|
+
};
|
|
322
|
+
const shellList = shells.phase === "ready" ? shells.shells : [];
|
|
323
|
+
let statusLabel;
|
|
324
|
+
switch (status.kind) {
|
|
325
|
+
case "connecting":
|
|
326
|
+
statusLabel = t("terminalStatusConnecting");
|
|
327
|
+
break;
|
|
328
|
+
case "running":
|
|
329
|
+
statusLabel = t("terminalStatusRunning");
|
|
330
|
+
break;
|
|
331
|
+
case "exited":
|
|
332
|
+
statusLabel = t("terminalStatusExited", {
|
|
333
|
+
code: status.code === null
|
|
334
|
+
? t("terminalNoExitCode")
|
|
335
|
+
: String(status.code),
|
|
336
|
+
});
|
|
337
|
+
break;
|
|
338
|
+
case "detached":
|
|
339
|
+
statusLabel = t("terminalStatusDetached");
|
|
340
|
+
break;
|
|
341
|
+
case "error":
|
|
342
|
+
statusLabel = status.message;
|
|
343
|
+
break;
|
|
344
|
+
case "idle":
|
|
345
|
+
statusLabel = undefined;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
const idle = status.kind === "idle";
|
|
349
|
+
const ended = status.kind === "exited" || status.kind === "error";
|
|
350
|
+
return (_jsxs("section", { style: ROOT_STYLE, "data-dsh-podman-terminal": true, children: [_jsxs("div", { style: TOOLBAR_STYLE, children: [_jsx(ContainerField, { containers: state.containers, workspaceSlug: workspaceSlug, container: container, disabled: state.workspaces.length === 0 || unknownWorkspace, label: t("terminalContainer"), defaultLabel: t("terminalDefaultContainer"), onContainer: (value) => {
|
|
351
|
+
setContainer(value);
|
|
352
|
+
setShell(undefined);
|
|
353
|
+
} }), _jsx("span", { style: fieldLabel, children: t("terminalShell") }), _jsx("select", { style: imageSelect, "aria-label": t("terminalShell"), value: shell ?? "", disabled: unknownWorkspace || shells.phase !== "ready" ||
|
|
354
|
+
shellList.length === 0, onChange: (event) => {
|
|
355
|
+
setShell(event.target.value);
|
|
356
|
+
}, children: shellList.length === 0
|
|
357
|
+
? (_jsx("option", { value: "", children: unknownWorkspace
|
|
358
|
+
? ""
|
|
359
|
+
: shells.phase === "loading"
|
|
360
|
+
? t("terminalLoading")
|
|
361
|
+
: t("terminalNoShells") }))
|
|
362
|
+
: shellList.map((entry) => (_jsx("option", { value: entry.path, children: entry.name }, entry.path))) }), (idle || ended) && (_jsx(Button, { variant: "outline", size: "sm", disabled: !canConnect, onClick: () => {
|
|
363
|
+
connect(!idle);
|
|
364
|
+
}, children: idle ? t("terminalConnect") : t("terminalReconnect") })), statusLabel === undefined
|
|
365
|
+
? null
|
|
366
|
+
: _jsx("span", { style: STATUS_STYLE, role: "status", children: statusLabel }), title === "" ? null : _jsx("span", { style: TITLE_STYLE, children: title })] }), state.workspaces.length === 0 && (_jsx("p", { style: ERROR_STYLE, role: "status", children: state.busy ? t("terminalLoading") : t("unavailable") })), target.phase === "failed" && (_jsx("p", { style: ERROR_STYLE, role: "alert", children: target.message })), shells.phase === "failed" && (_jsx("p", { style: ERROR_STYLE, role: "alert", children: t("terminalShellsFailed", { message: shells.message }) })), _jsx("div", { ref: node, className: XTERM_CLASS, style: SCREEN_STYLE })] }));
|
|
367
|
+
}
|
|
368
|
+
const ROOT_STYLE = {
|
|
369
|
+
display: "flex",
|
|
370
|
+
flex: 1,
|
|
371
|
+
flexDirection: "column",
|
|
372
|
+
boxSizing: "border-box",
|
|
373
|
+
height: "100%",
|
|
374
|
+
minHeight: 0,
|
|
375
|
+
color: "var(--dsw-alias-label-primary)",
|
|
376
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
377
|
+
fontSize: "13px",
|
|
378
|
+
};
|
|
379
|
+
const TOOLBAR_STYLE = {
|
|
380
|
+
display: "flex",
|
|
381
|
+
flexWrap: "wrap",
|
|
382
|
+
alignItems: "center",
|
|
383
|
+
gap: "8px",
|
|
384
|
+
padding: "6px 8px",
|
|
385
|
+
borderBottom: "1px solid var(--dsw-alias-border-l2)",
|
|
386
|
+
};
|
|
387
|
+
const STATUS_STYLE = {
|
|
388
|
+
fontSize: "12px",
|
|
389
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
390
|
+
};
|
|
391
|
+
const TITLE_STYLE = {
|
|
392
|
+
flex: 1,
|
|
393
|
+
minWidth: 0,
|
|
394
|
+
overflow: "hidden",
|
|
395
|
+
whiteSpace: "nowrap",
|
|
396
|
+
textOverflow: "ellipsis",
|
|
397
|
+
fontSize: "12px",
|
|
398
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
399
|
+
};
|
|
400
|
+
const ERROR_STYLE = {
|
|
401
|
+
margin: 0,
|
|
402
|
+
padding: "6px 10px",
|
|
403
|
+
fontSize: "12px",
|
|
404
|
+
color: "var(--dsw-alias-label-primary)",
|
|
405
|
+
background: "var(--dsw-alias-bg-layer-3)",
|
|
406
|
+
};
|
|
407
|
+
const SCREEN_STYLE = {
|
|
408
|
+
flex: 1,
|
|
409
|
+
minHeight: 0,
|
|
410
|
+
minWidth: 0,
|
|
411
|
+
overflow: "hidden",
|
|
412
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type PendingApproval } from "@deepseek-ai/dsh-client-ui-approval/client";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import type { Translate } from "./locales.js";
|
|
4
4
|
export declare const BUILTIN_PROMPT_PREFIX = "dsh_podman_builtin_";
|
|
5
5
|
export declare function ReadOnlyApprovalPanel(props: {
|
|
6
|
-
t:
|
|
6
|
+
t: Translate;
|
|
7
7
|
matched: PendingApproval;
|
|
8
8
|
}): ReactNode;
|
|
@@ -12,9 +12,14 @@ export function ReadOnlyApprovalPanel(props) {
|
|
|
12
12
|
}
|
|
13
13
|
function ReadOnlyApprovalFlow({ t, matched, }) {
|
|
14
14
|
const [answered, setAnswered] = useState(false);
|
|
15
|
+
const [error, setError] = useState("");
|
|
15
16
|
const answer = (outcome) => {
|
|
16
17
|
setAnswered(true);
|
|
17
|
-
|
|
18
|
+
setError("");
|
|
19
|
+
void matched.answer(outcome).catch((failure) => {
|
|
20
|
+
setAnswered(false);
|
|
21
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
22
|
+
});
|
|
18
23
|
};
|
|
19
24
|
return (_jsx("div", { style: {
|
|
20
25
|
display: "flex",
|
|
@@ -29,7 +34,7 @@ function ReadOnlyApprovalFlow({ t, matched, }) {
|
|
|
29
34
|
borderRadius: "20px",
|
|
30
35
|
background: "var(--dsw-specific-input-major)",
|
|
31
36
|
boxShadow: "var(--dsw-shadow-lv2)",
|
|
32
|
-
}, children: [_jsxs("div", { style: {
|
|
37
|
+
}, children: [_jsxs("div", { role: "status", "aria-live": "polite", style: {
|
|
33
38
|
display: "flex",
|
|
34
39
|
alignItems: "center",
|
|
35
40
|
gap: "8px",
|
|
@@ -54,7 +59,12 @@ function ReadOnlyApprovalFlow({ t, matched, }) {
|
|
|
54
59
|
fontWeight: 500,
|
|
55
60
|
lineHeight: "24px",
|
|
56
61
|
whiteSpace: "pre-line",
|
|
57
|
-
}, children: matched.reason ?? "" }) }),
|
|
62
|
+
}, children: matched.reason ?? "" }) }), error === "" ? null : (_jsx("p", { role: "alert", style: {
|
|
63
|
+
margin: 0,
|
|
64
|
+
padding: "8px 16px 0",
|
|
65
|
+
fontSize: "13px",
|
|
66
|
+
color: "var(--dsw-alias-state-error-primary)",
|
|
67
|
+
}, children: error })), _jsxs("div", { style: {
|
|
58
68
|
display: "flex",
|
|
59
69
|
justifyContent: "flex-end",
|
|
60
70
|
gap: "8px",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Origin-local key holding the target the card last started. */
|
|
2
|
+
export declare const TERMINAL_TARGET_KEY = "dsh.podman.terminal.v1";
|
|
3
|
+
/** What the card starts: a logical container name and a verified shell path. */
|
|
4
|
+
export interface TerminalTargetPreference {
|
|
5
|
+
/** `""`/"default" for the workspace's default container, or a named one. */
|
|
6
|
+
readonly container?: string;
|
|
7
|
+
/** Absolute path of a shell the host offered. */
|
|
8
|
+
readonly shell?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The two storage operations the preference needs. */
|
|
11
|
+
export interface PreferenceStorage {
|
|
12
|
+
getItem(key: string): string | null;
|
|
13
|
+
setItem(key: string, value: string): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the remembered target.
|
|
17
|
+
* @param storage - storage override for tests; the browser's localStorage otherwise.
|
|
18
|
+
* @returns the remembered target, or `undefined` when absent or unusable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readTerminalTarget(storage?: PreferenceStorage): TerminalTargetPreference | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Remember the target, best effort.
|
|
23
|
+
* @param target - the container and shell that were started.
|
|
24
|
+
* @param storage - storage override for tests; the browser's localStorage otherwise.
|
|
25
|
+
*/
|
|
26
|
+
export declare function writeTerminalTarget(target: TerminalTargetPreference, storage?: PreferenceStorage): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Browser-local memory of the target last started from the terminal card,
|
|
5
|
+
// mirroring the harness's own client preferences: an origin-local,
|
|
6
|
+
// `dsh.`-prefixed localStorage entry, read and written behind try/catch so
|
|
7
|
+
// private browsing or a quota failure can never break the card.
|
|
8
|
+
/** Origin-local key holding the target the card last started. */
|
|
9
|
+
export const TERMINAL_TARGET_KEY = "dsh.podman.terminal.v1";
|
|
10
|
+
// Accessed through globalThis so this module stays typecheckable without the
|
|
11
|
+
// DOM lib (the host test program imports it), and guarded because even reading
|
|
12
|
+
// the property can throw in a sandboxed frame.
|
|
13
|
+
function browserStorage() {
|
|
14
|
+
try {
|
|
15
|
+
const candidate = globalThis
|
|
16
|
+
.localStorage;
|
|
17
|
+
return candidate === undefined ? undefined : candidate;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read the remembered target.
|
|
25
|
+
* @param storage - storage override for tests; the browser's localStorage otherwise.
|
|
26
|
+
* @returns the remembered target, or `undefined` when absent or unusable.
|
|
27
|
+
*/
|
|
28
|
+
export function readTerminalTarget(storage) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = (storage ?? browserStorage())?.getItem(TERMINAL_TARGET_KEY);
|
|
31
|
+
if (raw === undefined || raw === null || raw === "")
|
|
32
|
+
return undefined;
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const record = parsed;
|
|
38
|
+
const container = typeof record.container === "string"
|
|
39
|
+
? record.container
|
|
40
|
+
: undefined;
|
|
41
|
+
const shell = typeof record.shell === "string" ? record.shell : undefined;
|
|
42
|
+
if (container === undefined && shell === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
...(container === undefined ? {} : { container }),
|
|
46
|
+
...(shell === undefined ? {} : { shell }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Malformed JSON, or storage that refuses to answer, leaves the card on its
|
|
51
|
+
// own defaults.
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Remember the target, best effort.
|
|
57
|
+
* @param target - the container and shell that were started.
|
|
58
|
+
* @param storage - storage override for tests; the browser's localStorage otherwise.
|
|
59
|
+
*/
|
|
60
|
+
export function writeTerminalTarget(target, storage) {
|
|
61
|
+
try {
|
|
62
|
+
(storage ?? browserStorage())?.setItem(TERMINAL_TARGET_KEY, JSON.stringify(target));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Private browsing or a quota failure leaves this launch usable.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Open or attach one browser terminal. */
|
|
2
|
+
export declare const TERMINAL_PATH = "/api/podman/terminal";
|
|
3
|
+
/** The shells a container really provides, discovered on its PATH. */
|
|
4
|
+
export declare const TERMINAL_SHELLS_PATH = "/api/podman/terminal/shells";
|
|
5
|
+
/** The terminals a session still has retained on the host. */
|
|
6
|
+
export declare const TERMINAL_RETAINED_PATH = "/api/podman/terminal/retained";
|
|
7
|
+
/** The workspace the host resolved for one session. */
|
|
8
|
+
export declare const TERMINAL_TARGET_PATH = "/api/podman/terminal/target";
|
|
9
|
+
/** The workspace a session runs in, resolved by the host from the session itself. */
|
|
10
|
+
export interface TerminalTargetView {
|
|
11
|
+
/** Workspace project name, as the card lists it. */
|
|
12
|
+
readonly workspace: string;
|
|
13
|
+
/** The harness workspace slug, used to filter that workspace's containers. */
|
|
14
|
+
readonly workspaceSlug: string;
|
|
15
|
+
}
|
|
16
|
+
/** One shell verified inside a container. */
|
|
17
|
+
export interface TerminalShellView {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
}
|
|
21
|
+
/** What the host knows about a retained terminal. */
|
|
22
|
+
export interface TerminalRetainedView {
|
|
23
|
+
readonly tabId: string;
|
|
24
|
+
readonly terminalId: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly workspace: string;
|
|
27
|
+
readonly container: string;
|
|
28
|
+
readonly shell: string;
|
|
29
|
+
}
|
|
30
|
+
/** Query parameters of the open/attach stream. */
|
|
31
|
+
export interface TerminalOpenQuery {
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
readonly tabId: string;
|
|
34
|
+
readonly workspace: string;
|
|
35
|
+
readonly container: string;
|
|
36
|
+
readonly shell: string;
|
|
37
|
+
readonly cols: number;
|
|
38
|
+
readonly rows: number;
|
|
39
|
+
}
|
|
40
|
+
/** Frames the host writes into the open/attach stream, one JSON object per line. */
|
|
41
|
+
export type TerminalFrame = {
|
|
42
|
+
readonly type: "ready";
|
|
43
|
+
readonly terminalId: string;
|
|
44
|
+
readonly workspace: string;
|
|
45
|
+
readonly container: string;
|
|
46
|
+
readonly shell: string;
|
|
47
|
+
readonly title: string;
|
|
48
|
+
readonly attached: boolean;
|
|
49
|
+
} | {
|
|
50
|
+
readonly type: "snapshot";
|
|
51
|
+
readonly screen: string;
|
|
52
|
+
} | {
|
|
53
|
+
readonly type: "data";
|
|
54
|
+
readonly data: string;
|
|
55
|
+
} | {
|
|
56
|
+
readonly type: "title";
|
|
57
|
+
readonly title: string;
|
|
58
|
+
} | {
|
|
59
|
+
readonly type: "exit";
|
|
60
|
+
readonly exitCode: number | null;
|
|
61
|
+
readonly signal: string | null;
|
|
62
|
+
} | {
|
|
63
|
+
readonly type: "error";
|
|
64
|
+
readonly message: string;
|
|
65
|
+
} | {
|
|
66
|
+
readonly type: "detached";
|
|
67
|
+
};
|
|
68
|
+
/** One control request against an open terminal. */
|
|
69
|
+
export type TerminalControl = {
|
|
70
|
+
readonly terminalId: string;
|
|
71
|
+
readonly kind: "input";
|
|
72
|
+
readonly data: string;
|
|
73
|
+
} | {
|
|
74
|
+
readonly terminalId: string;
|
|
75
|
+
readonly kind: "resize";
|
|
76
|
+
readonly cols: number;
|
|
77
|
+
readonly rows: number;
|
|
78
|
+
} | {
|
|
79
|
+
readonly terminalId: string;
|
|
80
|
+
readonly kind: "close";
|
|
81
|
+
} | {
|
|
82
|
+
readonly terminalId: string;
|
|
83
|
+
readonly kind: "rename";
|
|
84
|
+
readonly title: string;
|
|
85
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Wire contract between the Podman terminal tab (client half) and the host
|
|
5
|
+
// routes that bridge it to a guest terminal. Paths are exact: the connection
|
|
6
|
+
// service dispatches one route per path.
|
|
7
|
+
/** Open or attach one browser terminal. */
|
|
8
|
+
export const TERMINAL_PATH = "/api/podman/terminal";
|
|
9
|
+
/** The shells a container really provides, discovered on its PATH. */
|
|
10
|
+
export const TERMINAL_SHELLS_PATH = "/api/podman/terminal/shells";
|
|
11
|
+
/** The terminals a session still has retained on the host. */
|
|
12
|
+
export const TERMINAL_RETAINED_PATH = "/api/podman/terminal/retained";
|
|
13
|
+
/** The workspace the host resolved for one session. */
|
|
14
|
+
export const TERMINAL_TARGET_PATH = "/api/podman/terminal/target";
|