tmuxes 0.1.8 → 0.1.9
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/.node-version +1 -0
- package/.nvmrc +1 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
- package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
- package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
- package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
- package/AGENTS.md +15 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -21
- package/README.en.md +304 -0
- package/README.md +299 -295
- package/SECURITY.md +31 -0
- package/{public → client}/index.html +12 -13
- package/client/package.json +29 -0
- package/client/src/App.tsx +123 -0
- package/client/src/activity.ts +5 -0
- package/client/src/api.ts +130 -0
- package/client/src/attention.tsx +157 -0
- package/client/src/components/FileExplorer.tsx +156 -0
- package/client/src/components/FileViewer.tsx +194 -0
- package/client/src/components/SessionRow.tsx +108 -0
- package/client/src/components/SessionTree.tsx +197 -0
- package/client/src/components/SettingsButton.tsx +122 -0
- package/client/src/components/Sidebar.tsx +96 -0
- package/client/src/components/StatusBanner.tsx +31 -0
- package/client/src/components/TargetGroup.tsx +275 -0
- package/client/src/components/TerminalPanel.tsx +192 -0
- package/client/src/folders.ts +245 -0
- package/client/src/hooks/useTerminal.ts +67 -0
- package/client/src/hooks/useTmuxSocket.ts +65 -0
- package/client/src/i18n.ts +213 -0
- package/client/src/main.tsx +17 -0
- package/client/src/settings.tsx +87 -0
- package/client/src/styles.css +723 -0
- package/client/src/types.ts +93 -0
- package/client/src/util.ts +65 -0
- package/client/tsconfig.json +13 -0
- package/client/vite.config.ts +15 -0
- package/fig/fig1.png +0 -0
- package/package.json +28 -61
- package/scripts/prepack.mjs +35 -0
- package/{bin → server/bin}/tmuxes.js +36 -36
- package/server/package.json +61 -0
- package/server/src/agentHooks.ts +120 -0
- package/server/src/agentOutput.ts +36 -0
- package/server/src/agentState.ts +70 -0
- package/server/src/config.ts +31 -0
- package/server/src/exe.ts +34 -0
- package/server/src/exec.ts +61 -0
- package/server/src/files.ts +330 -0
- package/server/src/foldersStore.ts +114 -0
- package/server/src/index.ts +114 -0
- package/server/src/logger.ts +16 -0
- package/{dist/monitor.js → server/src/monitor.ts} +10 -9
- package/server/src/openBrowser.ts +28 -0
- package/{dist/platform.js → server/src/platform.ts} +4 -5
- package/server/src/rest/router.ts +290 -0
- package/server/src/targetCommand.ts +79 -0
- package/server/src/targets.ts +152 -0
- package/server/src/tmux/builder.ts +198 -0
- package/server/src/tmux/formats.ts +95 -0
- package/server/src/tmux/sessions.ts +204 -0
- package/server/src/validate.ts +79 -0
- package/server/src/windowsSsh.ts +239 -0
- package/server/src/winshell/manager.ts +296 -0
- package/server/src/ws/protocol.ts +15 -0
- package/server/src/ws/sshState.ts +36 -0
- package/server/src/ws/terminalSession.ts +207 -0
- package/server/src/ws/wsServer.ts +153 -0
- package/server/src/wsl.ts +38 -0
- package/server/test/agentHooks.test.ts +66 -0
- package/server/test/agentOutput.test.ts +26 -0
- package/server/test/agentState.test.ts +24 -0
- package/server/test/builder.test.ts +162 -0
- package/server/test/files.test.ts +81 -0
- package/server/test/formats.test.ts +123 -0
- package/server/test/monitor.test.ts +25 -0
- package/server/test/validate.test.ts +71 -0
- package/server/test/wsl.test.ts +18 -0
- package/server/tsconfig.json +9 -0
- package/server/vitest.config.ts +12 -0
- package/start.cmd +30 -0
- package/start.command +20 -0
- package/start.sh +20 -0
- package/tsconfig.base.json +19 -0
- package/dist/agentHooks.js +0 -91
- package/dist/agentHooks.js.map +0 -1
- package/dist/agentOutput.js +0 -30
- package/dist/agentOutput.js.map +0 -1
- package/dist/agentState.js +0 -45
- package/dist/agentState.js.map +0 -1
- package/dist/config.js +0 -32
- package/dist/config.js.map +0 -1
- package/dist/exe.js +0 -37
- package/dist/exe.js.map +0 -1
- package/dist/exec.js +0 -43
- package/dist/exec.js.map +0 -1
- package/dist/files.js +0 -243
- package/dist/files.js.map +0 -1
- package/dist/foldersStore.js +0 -103
- package/dist/foldersStore.js.map +0 -1
- package/dist/index.js +0 -117
- package/dist/index.js.map +0 -1
- package/dist/logger.js +0 -16
- package/dist/logger.js.map +0 -1
- package/dist/monitor.js.map +0 -1
- package/dist/openBrowser.js +0 -31
- package/dist/openBrowser.js.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/rest/router.js +0 -190
- package/dist/rest/router.js.map +0 -1
- package/dist/targetCommand.js +0 -41
- package/dist/targetCommand.js.map +0 -1
- package/dist/targets.js +0 -131
- package/dist/targets.js.map +0 -1
- package/dist/tmux/builder.js +0 -173
- package/dist/tmux/builder.js.map +0 -1
- package/dist/tmux/formats.js +0 -61
- package/dist/tmux/formats.js.map +0 -1
- package/dist/tmux/sessions.js +0 -157
- package/dist/tmux/sessions.js.map +0 -1
- package/dist/validate.js +0 -65
- package/dist/validate.js.map +0 -1
- package/dist/winshell/manager.js +0 -267
- package/dist/winshell/manager.js.map +0 -1
- package/dist/ws/protocol.js +0 -4
- package/dist/ws/protocol.js.map +0 -1
- package/dist/ws/sshState.js +0 -35
- package/dist/ws/sshState.js.map +0 -1
- package/dist/ws/terminalSession.js +0 -204
- package/dist/ws/terminalSession.js.map +0 -1
- package/dist/ws/wsServer.js +0 -151
- package/dist/ws/wsServer.js.map +0 -1
- package/dist/wsl.js +0 -35
- package/dist/wsl.js.map +0 -1
- package/public/assets/index-BpVrfoZw.js +0 -44
- package/public/assets/index-D_X5SnGx.css +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { OpenFile, Selection, Target } from '../types';
|
|
3
|
+
import { useSettings } from '../settings';
|
|
4
|
+
import { useI18n } from '../i18n';
|
|
5
|
+
import { TargetGroup } from './TargetGroup';
|
|
6
|
+
import { FileExplorer } from './FileExplorer';
|
|
7
|
+
import { SettingsButton } from './SettingsButton';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
targets: Target[];
|
|
11
|
+
selection: Selection | null;
|
|
12
|
+
nowMs: number;
|
|
13
|
+
loadError: string | null;
|
|
14
|
+
openFile: OpenFile | null;
|
|
15
|
+
select: (sel: Selection | null) => void;
|
|
16
|
+
onRefreshTargets: () => void;
|
|
17
|
+
onOpenFile: (file: OpenFile) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MIN_BOTTOM = 120;
|
|
21
|
+
|
|
22
|
+
export function Sidebar({
|
|
23
|
+
targets,
|
|
24
|
+
selection,
|
|
25
|
+
nowMs,
|
|
26
|
+
loadError,
|
|
27
|
+
openFile,
|
|
28
|
+
select,
|
|
29
|
+
onRefreshTargets,
|
|
30
|
+
onOpenFile,
|
|
31
|
+
}: Props) {
|
|
32
|
+
const { settings } = useSettings();
|
|
33
|
+
const { t } = useI18n();
|
|
34
|
+
const [bottomHeight, setBottomHeight] = useState(260);
|
|
35
|
+
const dragState = useRef<{ startY: number; startH: number } | null>(null);
|
|
36
|
+
|
|
37
|
+
const onDividerDown = useCallback(
|
|
38
|
+
(e: React.MouseEvent) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
dragState.current = { startY: e.clientY, startH: bottomHeight };
|
|
41
|
+
const onMove = (ev: MouseEvent) => {
|
|
42
|
+
if (!dragState.current) return;
|
|
43
|
+
const delta = dragState.current.startY - ev.clientY;
|
|
44
|
+
const max = window.innerHeight - 220;
|
|
45
|
+
setBottomHeight(Math.max(MIN_BOTTOM, Math.min(max, dragState.current.startH + delta)));
|
|
46
|
+
};
|
|
47
|
+
const onUp = () => {
|
|
48
|
+
dragState.current = null;
|
|
49
|
+
window.removeEventListener('mousemove', onMove);
|
|
50
|
+
window.removeEventListener('mouseup', onUp);
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener('mousemove', onMove);
|
|
53
|
+
window.addEventListener('mouseup', onUp);
|
|
54
|
+
},
|
|
55
|
+
[bottomHeight],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const selectedTarget = selection ? targets.find((t) => t.id === selection.targetId) : undefined;
|
|
59
|
+
const fileBrowsingEnabled = !selectedTarget || selectedTarget.kind !== 'winlocal';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="sidebar" style={{ fontSize: settings.sidebarFontSize }}>
|
|
63
|
+
<div className="sidebar-header">
|
|
64
|
+
<h1>tmuxes</h1>
|
|
65
|
+
<button onClick={onRefreshTargets} title={t.reloadTargets}>
|
|
66
|
+
⟳
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="sidebar-top">
|
|
71
|
+
{loadError && <div className="error-line">{loadError}</div>}
|
|
72
|
+
{targets.map((t) => (
|
|
73
|
+
<TargetGroup key={t.id} target={t} selection={selection} nowMs={nowMs} select={select} />
|
|
74
|
+
))}
|
|
75
|
+
{!loadError && targets.length === 0 && <div className="empty">{t.noTargets}</div>}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div className="sidebar-vdivider" onMouseDown={onDividerDown} title={t.dragResize} />
|
|
79
|
+
|
|
80
|
+
<div className="sidebar-bottom" style={{ height: bottomHeight }}>
|
|
81
|
+
<div className="section-label">{t.workingDirectory}</div>
|
|
82
|
+
<FileExplorer
|
|
83
|
+
selection={selection}
|
|
84
|
+
openFile={openFile}
|
|
85
|
+
enabled={fileBrowsingEnabled}
|
|
86
|
+
pauseOnError={selectedTarget?.kind === 'ssh'}
|
|
87
|
+
onOpenFile={onOpenFile}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="sidebar-footer">
|
|
92
|
+
<SettingsButton />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ConnStatus } from '../types';
|
|
2
|
+
import { useI18n } from '../i18n';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
status: ConnStatus;
|
|
6
|
+
onReconnect: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Overlay shown while connecting or when the link is degraded. Renders
|
|
10
|
+
* nothing once connected so the terminal is unobstructed. */
|
|
11
|
+
export function StatusBanner({ status, onReconnect }: Props) {
|
|
12
|
+
const { t } = useI18n();
|
|
13
|
+
if (status.kind === 'connected') return null;
|
|
14
|
+
|
|
15
|
+
if (status.kind === 'connecting') {
|
|
16
|
+
return (
|
|
17
|
+
<div className="status-banner">
|
|
18
|
+
<span className="spinner" />
|
|
19
|
+
<span className="msg">{t.connecting}</span>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isError = status.kind === 'error' || status.kind === 'ssh';
|
|
25
|
+
return (
|
|
26
|
+
<div className={`status-banner ${isError ? 'error' : ''}`}>
|
|
27
|
+
<span className="msg">{status.message}</span>
|
|
28
|
+
<button onClick={onReconnect}>{t.reconnect}</button>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { AttentionReason, SessionInfo, Selection, Target } from '../types';
|
|
3
|
+
import { api, ApiError } from '../api';
|
|
4
|
+
import { isValidSessionName } from '../util';
|
|
5
|
+
import { useFolders } from '../folders';
|
|
6
|
+
import { useAttention } from '../attention';
|
|
7
|
+
import { useI18n } from '../i18n';
|
|
8
|
+
import { SessionTree } from './SessionTree';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
target: Target;
|
|
12
|
+
selection: Selection | null;
|
|
13
|
+
nowMs: number;
|
|
14
|
+
select: (sel: Selection | null) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const POLL_MS = 5000;
|
|
18
|
+
|
|
19
|
+
function isAttentionReason(v: string | undefined): v is AttentionReason {
|
|
20
|
+
return v === 'done' || v === 'decision' || v === 'error';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TargetGroup({ target, selection, nowMs, select }: Props) {
|
|
24
|
+
// Expand the "local-ish" targets by default (local tmux, WSL, native shells).
|
|
25
|
+
const [expanded, setExpanded] = useState(
|
|
26
|
+
target.kind === 'local' || target.kind === 'wsl' || target.kind === 'winlocal',
|
|
27
|
+
);
|
|
28
|
+
const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
const [showForm, setShowForm] = useState(false);
|
|
33
|
+
const [newName, setNewName] = useState('');
|
|
34
|
+
const [newCommand, setNewCommand] = useState('');
|
|
35
|
+
const [newShell, setNewShell] = useState(target.shells?.[0]?.id ?? '');
|
|
36
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
37
|
+
const [creating, setCreating] = useState(false);
|
|
38
|
+
|
|
39
|
+
const folders = useFolders(target.id, expanded, target.kind === 'ssh');
|
|
40
|
+
const attention = useAttention();
|
|
41
|
+
const { t } = useI18n();
|
|
42
|
+
|
|
43
|
+
// Avoid overlapping fetches when a poll and a manual refresh race.
|
|
44
|
+
const inFlight = useRef(false);
|
|
45
|
+
// Avoid repeated SSH login failures that can look like brute-force attempts.
|
|
46
|
+
// Collapse/re-expand the target to retry after fixing keys, VPN, or host access.
|
|
47
|
+
const pollingPaused = useRef(false);
|
|
48
|
+
// Last-seen agent hook event key per session; first sight is a baseline.
|
|
49
|
+
const lastAgentEvent = useRef<Map<string, string>>(new Map());
|
|
50
|
+
// PermissionRequest can be auto-reviewed by the agent and disappear quickly.
|
|
51
|
+
// Only notify if the same waiting/decision event survives one more refresh.
|
|
52
|
+
const decisionCandidates = useRef<Map<string, { key: string; notified: boolean }>>(new Map());
|
|
53
|
+
|
|
54
|
+
const eventKey = (s: SessionInfo): string => {
|
|
55
|
+
if (!s.agentKind || !s.agentState || !s.agentNonce) return '';
|
|
56
|
+
return [
|
|
57
|
+
s.agentKind,
|
|
58
|
+
s.agentState,
|
|
59
|
+
s.attentionReason ?? '',
|
|
60
|
+
s.agentEvent ?? '',
|
|
61
|
+
s.agentNonce,
|
|
62
|
+
].join(':');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const detectAttention = useCallback(
|
|
66
|
+
(sessions: SessionInfo[]) => {
|
|
67
|
+
const present = new Set<string>();
|
|
68
|
+
for (const s of sessions) {
|
|
69
|
+
present.add(s.name);
|
|
70
|
+
const key = eventKey(s);
|
|
71
|
+
const seen = lastAgentEvent.current.has(s.name);
|
|
72
|
+
const prev = lastAgentEvent.current.get(s.name);
|
|
73
|
+
lastAgentEvent.current.set(s.name, key);
|
|
74
|
+
|
|
75
|
+
if (s.agentState === 'running') {
|
|
76
|
+
decisionCandidates.current.delete(s.name);
|
|
77
|
+
attention.clearAlert(target.id, s.name);
|
|
78
|
+
} else if (s.agentState === 'waiting' && s.attentionReason === 'decision' && key) {
|
|
79
|
+
const candidate = decisionCandidates.current.get(s.name);
|
|
80
|
+
if (candidate?.key === key) {
|
|
81
|
+
if (seen && !candidate.notified) {
|
|
82
|
+
attention.fire(target.id, s.name, 'decision');
|
|
83
|
+
decisionCandidates.current.set(s.name, { key, notified: true });
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
decisionCandidates.current.set(s.name, { key, notified: false });
|
|
87
|
+
}
|
|
88
|
+
} else if (
|
|
89
|
+
seen &&
|
|
90
|
+
key &&
|
|
91
|
+
key !== prev &&
|
|
92
|
+
isAttentionReason(s.attentionReason)
|
|
93
|
+
) {
|
|
94
|
+
decisionCandidates.current.delete(s.name);
|
|
95
|
+
attention.fire(target.id, s.name, s.attentionReason);
|
|
96
|
+
} else {
|
|
97
|
+
decisionCandidates.current.delete(s.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Forget sessions that vanished.
|
|
101
|
+
for (const name of [...lastAgentEvent.current.keys()]) {
|
|
102
|
+
if (!present.has(name)) {
|
|
103
|
+
lastAgentEvent.current.delete(name);
|
|
104
|
+
decisionCandidates.current.delete(name);
|
|
105
|
+
attention.clearAlert(target.id, name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[attention, target.id],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const refresh = useCallback(async () => {
|
|
113
|
+
if (pollingPaused.current) return;
|
|
114
|
+
if (inFlight.current) return;
|
|
115
|
+
inFlight.current = true;
|
|
116
|
+
setLoading(true);
|
|
117
|
+
try {
|
|
118
|
+
const { sessions } = await api.getSessions(target.id);
|
|
119
|
+
setSessions(sessions);
|
|
120
|
+
detectAttention(sessions);
|
|
121
|
+
setError(null);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
setError(e instanceof ApiError ? e.message : t.failedListSessions);
|
|
124
|
+
if (target.kind === 'ssh') pollingPaused.current = true;
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(false);
|
|
127
|
+
inFlight.current = false;
|
|
128
|
+
}
|
|
129
|
+
}, [target.id, target.kind, detectAttention, t.failedListSessions]);
|
|
130
|
+
|
|
131
|
+
const manualReconnect = useCallback(() => {
|
|
132
|
+
pollingPaused.current = false;
|
|
133
|
+
setError(null);
|
|
134
|
+
void refresh();
|
|
135
|
+
}, [refresh]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!expanded) return;
|
|
139
|
+
pollingPaused.current = false;
|
|
140
|
+
void refresh();
|
|
141
|
+
const id = window.setInterval(() => void refresh(), POLL_MS);
|
|
142
|
+
return () => window.clearInterval(id);
|
|
143
|
+
}, [expanded, refresh]);
|
|
144
|
+
|
|
145
|
+
const submitCreate = async () => {
|
|
146
|
+
const name = newName.trim();
|
|
147
|
+
if (name && !isValidSessionName(name)) {
|
|
148
|
+
setFormError(t.invalidSessionName);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
setCreating(true);
|
|
152
|
+
setFormError(null);
|
|
153
|
+
try {
|
|
154
|
+
const { name: created } = await api.createSession(target.id, {
|
|
155
|
+
name: name || undefined,
|
|
156
|
+
command: newCommand.trim() || undefined,
|
|
157
|
+
shell: target.kind === 'winlocal' ? newShell || undefined : undefined,
|
|
158
|
+
});
|
|
159
|
+
setNewName('');
|
|
160
|
+
setNewCommand('');
|
|
161
|
+
setShowForm(false);
|
|
162
|
+
await refresh();
|
|
163
|
+
select({ targetId: target.id, session: created });
|
|
164
|
+
} catch (e) {
|
|
165
|
+
setFormError(e instanceof ApiError ? e.message : t.failedCreateSession);
|
|
166
|
+
} finally {
|
|
167
|
+
setCreating(false);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleRename = async (oldName: string, next: string) => {
|
|
172
|
+
try {
|
|
173
|
+
await api.renameSession(target.id, oldName, next);
|
|
174
|
+
await refresh();
|
|
175
|
+
if (selection?.targetId === target.id && selection.session === oldName) {
|
|
176
|
+
select({ targetId: target.id, session: next });
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
setError(e instanceof ApiError ? e.message : t.renameFailed);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleKill = async (name: string) => {
|
|
184
|
+
try {
|
|
185
|
+
await api.killSession(target.id, name);
|
|
186
|
+
if (selection?.targetId === target.id && selection.session === name) select(null);
|
|
187
|
+
await refresh();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
setError(e instanceof ApiError ? e.message : t.killFailed);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="target-group">
|
|
195
|
+
<div className="target-head" onClick={() => setExpanded((v) => !v)}>
|
|
196
|
+
<span className="caret">{expanded ? '▾' : '▸'}</span>
|
|
197
|
+
<span className="label">{target.label}</span>
|
|
198
|
+
<span className="kind">{target.kind}</span>
|
|
199
|
+
{expanded && loading && <span className="badge loading">…</span>}
|
|
200
|
+
{expanded && error && (
|
|
201
|
+
<span className="badge err" title={error}>
|
|
202
|
+
!
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{expanded && (
|
|
208
|
+
<div className="session-list">
|
|
209
|
+
{error && (
|
|
210
|
+
<div className="error-line">
|
|
211
|
+
<span>{error}</span>
|
|
212
|
+
{target.kind === 'ssh' && (
|
|
213
|
+
<button onClick={manualReconnect} disabled={loading}>
|
|
214
|
+
{t.reconnect}
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
<SessionTree
|
|
221
|
+
targetId={target.id}
|
|
222
|
+
sessions={sessions ?? []}
|
|
223
|
+
folders={folders}
|
|
224
|
+
selection={selection}
|
|
225
|
+
nowMs={nowMs}
|
|
226
|
+
select={select}
|
|
227
|
+
onRename={(oldName, next) => void handleRename(oldName, next)}
|
|
228
|
+
onKill={(name) => void handleKill(name)}
|
|
229
|
+
/>
|
|
230
|
+
|
|
231
|
+
<div className="list-toolbar">
|
|
232
|
+
<button onClick={() => folders.addFolder(null, t.folderDefaultName)} title={t.newFolderTitle}>
|
|
233
|
+
{t.newFolder}
|
|
234
|
+
</button>
|
|
235
|
+
<button onClick={() => setShowForm((v) => !v)}>{t.newSession}</button>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{showForm && (
|
|
239
|
+
<div className="create-form" onClick={(e) => e.stopPropagation()}>
|
|
240
|
+
{target.kind === 'winlocal' && target.shells && target.shells.length > 0 && (
|
|
241
|
+
<select value={newShell} onChange={(e) => setNewShell(e.target.value)}>
|
|
242
|
+
{target.shells.map((s) => (
|
|
243
|
+
<option key={s.id} value={s.id}>
|
|
244
|
+
{s.label}
|
|
245
|
+
</option>
|
|
246
|
+
))}
|
|
247
|
+
</select>
|
|
248
|
+
)}
|
|
249
|
+
<input
|
|
250
|
+
autoFocus
|
|
251
|
+
placeholder={t.nameOptional}
|
|
252
|
+
value={newName}
|
|
253
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
254
|
+
onKeyDown={(e) => e.key === 'Enter' && void submitCreate()}
|
|
255
|
+
/>
|
|
256
|
+
<input
|
|
257
|
+
placeholder={t.commandOptional}
|
|
258
|
+
value={newCommand}
|
|
259
|
+
onChange={(e) => setNewCommand(e.target.value)}
|
|
260
|
+
onKeyDown={(e) => e.key === 'Enter' && void submitCreate()}
|
|
261
|
+
/>
|
|
262
|
+
{formError && <div className="field-error">{formError}</div>}
|
|
263
|
+
<div className="row">
|
|
264
|
+
<button className="primary" disabled={creating} onClick={() => void submitCreate()}>
|
|
265
|
+
{t.create}
|
|
266
|
+
</button>
|
|
267
|
+
<button onClick={() => setShowForm(false)}>{t.cancel}</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createTerminal, type TerminalHandle } from '../hooks/useTerminal';
|
|
3
|
+
import { createTmuxSocket, type TmuxSocket } from '../hooks/useTmuxSocket';
|
|
4
|
+
import { api, ApiError, terminalSocketUrl } from '../api';
|
|
5
|
+
import type { ConnStatus, LaunchAgent, Target } from '../types';
|
|
6
|
+
import { useI18n } from '../i18n';
|
|
7
|
+
import { StatusBanner } from './StatusBanner';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
targetId: string;
|
|
11
|
+
targetKind: Target['kind'];
|
|
12
|
+
targetLabel: string;
|
|
13
|
+
session: string;
|
|
14
|
+
fontSize: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RESIZE_DEBOUNCE_MS = 80;
|
|
18
|
+
|
|
19
|
+
/** Mounted with key={targetId/session}, so a selection change is a full
|
|
20
|
+
* remount — no stale terminal/socket state. */
|
|
21
|
+
export function TerminalPanel({ targetId, targetKind, targetLabel, session, fontSize }: Props) {
|
|
22
|
+
const { t } = useI18n();
|
|
23
|
+
const tRef = useRef(t);
|
|
24
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const handleRef = useRef<TerminalHandle | null>(null);
|
|
26
|
+
const socketRef = useRef<TmuxSocket | null>(null);
|
|
27
|
+
const [status, setStatus] = useState<ConnStatus>({ kind: 'connecting' });
|
|
28
|
+
const [generation, setGeneration] = useState(0);
|
|
29
|
+
const [startingAgent, setStartingAgent] = useState<LaunchAgent | null>(null);
|
|
30
|
+
const [agentError, setAgentError] = useState<string | null>(null);
|
|
31
|
+
const terminalRetryUsed = useRef(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
terminalRetryUsed.current = false;
|
|
35
|
+
}, [targetId, session]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
tRef.current = t;
|
|
39
|
+
}, [t]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const host = hostRef.current;
|
|
43
|
+
if (!host) return;
|
|
44
|
+
|
|
45
|
+
setStatus({ kind: 'connecting' });
|
|
46
|
+
|
|
47
|
+
const handle = createTerminal(host, fontSize);
|
|
48
|
+
handleRef.current = handle;
|
|
49
|
+
const initial = handle.refit() ?? { cols: 80, rows: 24 };
|
|
50
|
+
let lastCols = initial.cols;
|
|
51
|
+
let lastRows = initial.rows;
|
|
52
|
+
let exited = false;
|
|
53
|
+
|
|
54
|
+
const retryOnce = (): boolean => {
|
|
55
|
+
if (targetKind !== 'ssh' || terminalRetryUsed.current) return false;
|
|
56
|
+
terminalRetryUsed.current = true;
|
|
57
|
+
setStatus({ kind: 'connecting' });
|
|
58
|
+
setGeneration((g) => g + 1);
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const socket = createTmuxSocket(
|
|
63
|
+
terminalSocketUrl(targetId, session, initial.cols, initial.rows),
|
|
64
|
+
{
|
|
65
|
+
onOpen: () => {
|
|
66
|
+
/* wait for bytes before declaring connected */
|
|
67
|
+
},
|
|
68
|
+
onOutput: (bytes) => {
|
|
69
|
+
handle.term.write(bytes);
|
|
70
|
+
setStatus((s) => (s.kind === 'connecting' || s.kind === 'ssh' ? { kind: 'connected' } : s));
|
|
71
|
+
},
|
|
72
|
+
onControl: (msg) => {
|
|
73
|
+
if (msg.type === 'ready') {
|
|
74
|
+
// Re-assert geometry now that the PTY exists (matters for ssh).
|
|
75
|
+
socket.resize(handle.term.cols, handle.term.rows);
|
|
76
|
+
} else if (msg.type === 'ssh') {
|
|
77
|
+
setStatus((s) => (s.kind === 'connecting' ? { kind: 'ssh', message: msg.message } : s));
|
|
78
|
+
} else if (msg.type === 'error') {
|
|
79
|
+
setStatus({ kind: 'error', message: msg.message });
|
|
80
|
+
} else if (msg.type === 'exit') {
|
|
81
|
+
exited = true;
|
|
82
|
+
if (msg.code !== 0 && retryOnce()) return;
|
|
83
|
+
setStatus({
|
|
84
|
+
kind: 'disconnected',
|
|
85
|
+
message:
|
|
86
|
+
msg.code === 0 || msg.code === null
|
|
87
|
+
? tRef.current.sessionEnded
|
|
88
|
+
: tRef.current.sessionEndedExit(msg.code),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onClose: () => {
|
|
93
|
+
if (!exited) {
|
|
94
|
+
if (retryOnce()) return;
|
|
95
|
+
setStatus({
|
|
96
|
+
kind: targetKind === 'ssh' ? 'ssh' : 'disconnected',
|
|
97
|
+
message:
|
|
98
|
+
targetKind === 'ssh'
|
|
99
|
+
? tRef.current.sshInterrupted
|
|
100
|
+
: tRef.current.disconnected,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
socketRef.current = socket;
|
|
108
|
+
const dataSub = handle.term.onData((d) => socket.sendInput(d));
|
|
109
|
+
handle.term.focus();
|
|
110
|
+
|
|
111
|
+
// Debounced fit on container resize → push new geometry to the PTY.
|
|
112
|
+
let resizeTimer: number | undefined;
|
|
113
|
+
const observer = new ResizeObserver(() => {
|
|
114
|
+
window.clearTimeout(resizeTimer);
|
|
115
|
+
resizeTimer = window.setTimeout(() => {
|
|
116
|
+
const geo = handle.refit();
|
|
117
|
+
if (geo && (geo.cols !== lastCols || geo.rows !== lastRows)) {
|
|
118
|
+
lastCols = geo.cols;
|
|
119
|
+
lastRows = geo.rows;
|
|
120
|
+
socket.resize(geo.cols, geo.rows);
|
|
121
|
+
}
|
|
122
|
+
}, RESIZE_DEBOUNCE_MS);
|
|
123
|
+
});
|
|
124
|
+
observer.observe(host);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
window.clearTimeout(resizeTimer);
|
|
128
|
+
observer.disconnect();
|
|
129
|
+
dataSub.dispose();
|
|
130
|
+
socket.close();
|
|
131
|
+
handle.dispose();
|
|
132
|
+
handleRef.current = null;
|
|
133
|
+
socketRef.current = null;
|
|
134
|
+
};
|
|
135
|
+
}, [targetId, targetKind, session, generation]);
|
|
136
|
+
|
|
137
|
+
// Live-apply terminal font size without remounting (preserves scrollback).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const handle = handleRef.current;
|
|
140
|
+
if (!handle) return;
|
|
141
|
+
if (handle.term.options.fontSize === fontSize) return;
|
|
142
|
+
handle.term.options.fontSize = fontSize;
|
|
143
|
+
const geo = handle.refit();
|
|
144
|
+
if (geo) socketRef.current?.resize(geo.cols, geo.rows);
|
|
145
|
+
}, [fontSize]);
|
|
146
|
+
|
|
147
|
+
const launchAgent = async (agent: LaunchAgent) => {
|
|
148
|
+
setStartingAgent(agent);
|
|
149
|
+
setAgentError(null);
|
|
150
|
+
try {
|
|
151
|
+
await api.launchAgent(targetId, session, agent);
|
|
152
|
+
handleRef.current?.term.focus();
|
|
153
|
+
} catch (e) {
|
|
154
|
+
setAgentError(e instanceof ApiError ? e.message : t.failedLaunchAgent);
|
|
155
|
+
} finally {
|
|
156
|
+
setStartingAgent(null);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="panel">
|
|
162
|
+
{targetKind !== 'winlocal' && (
|
|
163
|
+
<div className="agent-toolbar">
|
|
164
|
+
<button
|
|
165
|
+
disabled={startingAgent !== null}
|
|
166
|
+
onClick={() => void launchAgent('claude')}
|
|
167
|
+
title={t.runClaude}
|
|
168
|
+
>
|
|
169
|
+
claude
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
disabled={startingAgent !== null}
|
|
173
|
+
onClick={() => void launchAgent('codex')}
|
|
174
|
+
title={t.runCodex}
|
|
175
|
+
>
|
|
176
|
+
codex
|
|
177
|
+
</button>
|
|
178
|
+
{agentError && <span className="agent-error" title={agentError}>!</span>}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
<div className="term-host" ref={hostRef} />
|
|
182
|
+
<StatusBanner status={status} onReconnect={() => setGeneration((g) => g + 1)} />
|
|
183
|
+
{status.kind !== 'connected' && status.kind !== 'connecting' && (
|
|
184
|
+
<div className="panel-placeholder" style={{ pointerEvents: 'none' }}>
|
|
185
|
+
<div style={{ opacity: 0.4 }}>
|
|
186
|
+
{targetLabel} · {session}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|