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,194 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { FilePreview, OpenFile } from '../types';
|
|
3
|
+
import { api, ApiError } from '../api';
|
|
4
|
+
import { useSettings } from '../settings';
|
|
5
|
+
import { useI18n } from '../i18n';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
file: OpenFile;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** New undo checkpoint after this idle gap, so a burst of typing collapses
|
|
13
|
+
* into a single undo step. */
|
|
14
|
+
const COALESCE_MS = 500;
|
|
15
|
+
|
|
16
|
+
/** Read-only preview + editor for a text file. Editable only when the file is
|
|
17
|
+
* text and was not truncated (saving a truncated preview would lose data). */
|
|
18
|
+
export function FileViewer({ file, onClose }: Props) {
|
|
19
|
+
const { settings } = useSettings();
|
|
20
|
+
const { t } = useI18n();
|
|
21
|
+
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
22
|
+
const lastEditRef = useRef(0);
|
|
23
|
+
|
|
24
|
+
const [preview, setPreview] = useState<FilePreview | null>(null);
|
|
25
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
|
|
28
|
+
const [text, setText] = useState('');
|
|
29
|
+
const [baseline, setBaseline] = useState('');
|
|
30
|
+
const [past, setPast] = useState<string[]>([]);
|
|
31
|
+
const [future, setFuture] = useState<string[]>([]);
|
|
32
|
+
const [saving, setSaving] = useState(false);
|
|
33
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const editable = !!preview && !preview.binary && !preview.truncated;
|
|
36
|
+
const dirty = editable && text !== baseline;
|
|
37
|
+
|
|
38
|
+
// Load (or reload) the file.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
setLoading(true);
|
|
42
|
+
setLoadError(null);
|
|
43
|
+
setSaveError(null);
|
|
44
|
+
setPreview(null);
|
|
45
|
+
setPast([]);
|
|
46
|
+
setFuture([]);
|
|
47
|
+
lastEditRef.current = 0;
|
|
48
|
+
api
|
|
49
|
+
.getFile(file.targetId, file.session, file.path)
|
|
50
|
+
.then((p) => {
|
|
51
|
+
if (cancelled) return;
|
|
52
|
+
setPreview(p);
|
|
53
|
+
setText(p.content);
|
|
54
|
+
setBaseline(p.content);
|
|
55
|
+
})
|
|
56
|
+
.catch((e) => {
|
|
57
|
+
if (!cancelled) setLoadError(e instanceof ApiError ? e.message : t.failedReadFile);
|
|
58
|
+
})
|
|
59
|
+
.finally(() => {
|
|
60
|
+
if (!cancelled) setLoading(false);
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
};
|
|
65
|
+
}, [file.targetId, file.session, file.path, t.failedReadFile]);
|
|
66
|
+
|
|
67
|
+
const onType = (next: string) => {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (now - lastEditRef.current > COALESCE_MS) {
|
|
70
|
+
setPast((p) => [...p, text]); // checkpoint = value at start of this burst
|
|
71
|
+
setFuture([]);
|
|
72
|
+
}
|
|
73
|
+
lastEditRef.current = now;
|
|
74
|
+
setText(next);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Flat functional updates (no nested setState) so the handlers are
|
|
78
|
+
// StrictMode-safe; past/future/text come from the current render.
|
|
79
|
+
const undo = () => {
|
|
80
|
+
if (past.length === 0) return;
|
|
81
|
+
const prev = past[past.length - 1];
|
|
82
|
+
setFuture((f) => [...f, text]);
|
|
83
|
+
setPast((p) => p.slice(0, -1));
|
|
84
|
+
setText(prev);
|
|
85
|
+
lastEditRef.current = 0;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const redo = () => {
|
|
89
|
+
if (future.length === 0) return;
|
|
90
|
+
const next = future[future.length - 1];
|
|
91
|
+
setPast((p) => [...p, text]);
|
|
92
|
+
setFuture((f) => f.slice(0, -1));
|
|
93
|
+
setText(next);
|
|
94
|
+
lastEditRef.current = 0;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const save = async () => {
|
|
98
|
+
if (!editable || !dirty || saving) return;
|
|
99
|
+
setSaving(true);
|
|
100
|
+
setSaveError(null);
|
|
101
|
+
try {
|
|
102
|
+
await api.saveFile(file.targetId, file.session, file.path, text);
|
|
103
|
+
setBaseline(text);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
setSaveError(e instanceof ApiError ? e.message : t.saveFailed);
|
|
106
|
+
} finally {
|
|
107
|
+
setSaving(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
112
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
113
|
+
const k = e.key.toLowerCase();
|
|
114
|
+
if (mod && k === 's') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
void save();
|
|
117
|
+
} else if (mod && k === 'z' && !e.shiftKey) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
undo();
|
|
120
|
+
} else if (mod && (k === 'y' || (k === 'z' && e.shiftKey))) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
redo();
|
|
123
|
+
} else if (e.key === 'Tab') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const ta = taRef.current;
|
|
126
|
+
if (!ta) return;
|
|
127
|
+
const start = ta.selectionStart;
|
|
128
|
+
const end = ta.selectionEnd;
|
|
129
|
+
onType(text.slice(0, start) + ' ' + text.slice(end));
|
|
130
|
+
requestAnimationFrame(() => {
|
|
131
|
+
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const requestClose = () => {
|
|
137
|
+
if (dirty && !confirm(t.discardUnsaved)) return;
|
|
138
|
+
onClose();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="viewer">
|
|
143
|
+
<div className="viewer-head">
|
|
144
|
+
<span className="viewer-name" title={file.path}>
|
|
145
|
+
{dirty && <span className="dirty-dot" title={t.unsavedChanges}>●</span>}
|
|
146
|
+
{file.name}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="viewer-path">{file.path}</span>
|
|
149
|
+
<div className="viewer-head-spacer" />
|
|
150
|
+
{editable && (
|
|
151
|
+
<>
|
|
152
|
+
<button onClick={undo} disabled={past.length === 0} title={t.undo}>
|
|
153
|
+
↶
|
|
154
|
+
</button>
|
|
155
|
+
<button onClick={redo} disabled={future.length === 0} title={t.redo}>
|
|
156
|
+
↷
|
|
157
|
+
</button>
|
|
158
|
+
<button className="primary" onClick={() => void save()} disabled={!dirty || saving} title={t.saveShortcut}>
|
|
159
|
+
{saving ? t.saving : t.save}
|
|
160
|
+
</button>
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
{preview?.truncated && <span className="viewer-note">{t.truncatedReadOnly}</span>}
|
|
164
|
+
<button onClick={requestClose} title={t.closeFile}>
|
|
165
|
+
✕
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{saveError && <div className="viewer-msg error">{saveError}</div>}
|
|
170
|
+
|
|
171
|
+
<div className="viewer-body">
|
|
172
|
+
{loading && <div className="viewer-msg">{t.loading}</div>}
|
|
173
|
+
{loadError && <div className="viewer-msg error">{loadError}</div>}
|
|
174
|
+
{preview && preview.binary && <div className="viewer-msg">{t.binaryNotShown}</div>}
|
|
175
|
+
{preview && !preview.binary && editable && (
|
|
176
|
+
<textarea
|
|
177
|
+
ref={taRef}
|
|
178
|
+
className="viewer-editor"
|
|
179
|
+
style={{ fontSize: settings.viewerFontSize }}
|
|
180
|
+
value={text}
|
|
181
|
+
spellCheck={false}
|
|
182
|
+
onChange={(e) => onType(e.target.value)}
|
|
183
|
+
onKeyDown={onKeyDown}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
{preview && !preview.binary && !editable && (
|
|
187
|
+
<pre className="viewer-pre" style={{ fontSize: settings.viewerFontSize }}>
|
|
188
|
+
{preview.content}
|
|
189
|
+
</pre>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { SessionInfo } from '../types';
|
|
3
|
+
import { ago, isValidSessionName } from '../util';
|
|
4
|
+
import { useAttention } from '../attention';
|
|
5
|
+
import { isSessionActive } from '../activity';
|
|
6
|
+
import { agentStatusLabel, attentionText, attentionTitle, useI18n } from '../i18n';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
targetId: string;
|
|
10
|
+
session: SessionInfo;
|
|
11
|
+
selected: boolean;
|
|
12
|
+
nowMs: number;
|
|
13
|
+
depth?: number;
|
|
14
|
+
onSelect: () => void;
|
|
15
|
+
onRename: (newName: string) => void;
|
|
16
|
+
onKill: () => void;
|
|
17
|
+
onDragStart?: (e: React.DragEvent) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SessionRow({
|
|
21
|
+
targetId,
|
|
22
|
+
session,
|
|
23
|
+
selected,
|
|
24
|
+
nowMs,
|
|
25
|
+
depth = 0,
|
|
26
|
+
onSelect,
|
|
27
|
+
onRename,
|
|
28
|
+
onKill,
|
|
29
|
+
onDragStart,
|
|
30
|
+
}: Props) {
|
|
31
|
+
const attention = useAttention();
|
|
32
|
+
const { language, t } = useI18n();
|
|
33
|
+
const reason = attention.reasonFor(targetId, session.name);
|
|
34
|
+
const active = isSessionActive(session);
|
|
35
|
+
const [renaming, setRenaming] = useState(false);
|
|
36
|
+
const [draft, setDraft] = useState(session.name);
|
|
37
|
+
|
|
38
|
+
const commitRename = () => {
|
|
39
|
+
const next = draft.trim();
|
|
40
|
+
if (next && next !== session.name && isValidSessionName(next)) {
|
|
41
|
+
onRename(next);
|
|
42
|
+
}
|
|
43
|
+
setRenaming(false);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const status = agentStatusLabel(session, t);
|
|
47
|
+
|
|
48
|
+
if (renaming) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="session-row" style={{ paddingLeft: 8 + depth * 14 }}>
|
|
51
|
+
<input
|
|
52
|
+
autoFocus
|
|
53
|
+
value={draft}
|
|
54
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === 'Enter') commitRename();
|
|
57
|
+
else if (e.key === 'Escape') setRenaming(false);
|
|
58
|
+
}}
|
|
59
|
+
onBlur={() => setRenaming(false)}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={`session-row ${selected ? 'selected' : ''} ${reason ? 'attention' : ''}`}
|
|
68
|
+
style={{ paddingLeft: 8 + depth * 14 }}
|
|
69
|
+
draggable={!!onDragStart}
|
|
70
|
+
onDragStart={onDragStart}
|
|
71
|
+
onClick={onSelect}
|
|
72
|
+
title={`${session.name} - ${status} - ${session.windows} ${t.windowsShort}`}
|
|
73
|
+
>
|
|
74
|
+
<span className={`dot ${active ? 'active' : 'inactive'}`} title={status} />
|
|
75
|
+
<span className="name">{session.name}</span>
|
|
76
|
+
{reason && (
|
|
77
|
+
<span className={`attn-badge ${reason}`} title={attentionTitle(reason, t)}>
|
|
78
|
+
{attentionText(reason, t)}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
<span className="meta">
|
|
82
|
+
{session.windows} {t.windowsShort}{session.created ? ` · ${ago(session.created, nowMs, language)}` : ''}
|
|
83
|
+
</span>
|
|
84
|
+
<span className="row-actions">
|
|
85
|
+
<button
|
|
86
|
+
onClick={(e) => {
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
setDraft(session.name);
|
|
89
|
+
setRenaming(true);
|
|
90
|
+
}}
|
|
91
|
+
title={t.rename}
|
|
92
|
+
>
|
|
93
|
+
✎
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
className="danger"
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
if (confirm(t.killConfirm(session.name))) onKill();
|
|
100
|
+
}}
|
|
101
|
+
title={t.kill}
|
|
102
|
+
>
|
|
103
|
+
✕
|
|
104
|
+
</button>
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useEffect, useState, type DragEvent, type ReactNode } from 'react';
|
|
2
|
+
import type { SessionInfo, Selection } from '../types';
|
|
3
|
+
import type { FoldersApi } from '../folders';
|
|
4
|
+
import { useI18n } from '../i18n';
|
|
5
|
+
import { SessionRow } from './SessionRow';
|
|
6
|
+
|
|
7
|
+
const DND_TYPE = 'application/x-tmuxes';
|
|
8
|
+
type DragPayload = { kind: 'session'; name: string } | { kind: 'folder'; id: string };
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
targetId: string;
|
|
12
|
+
sessions: SessionInfo[];
|
|
13
|
+
folders: FoldersApi;
|
|
14
|
+
selection: Selection | null;
|
|
15
|
+
nowMs: number;
|
|
16
|
+
select: (sel: Selection | null) => void;
|
|
17
|
+
onRename: (oldName: string, newName: string) => void;
|
|
18
|
+
onKill: (name: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SessionTree({ targetId, sessions, folders, selection, nowMs, select, onRename, onKill }: Props) {
|
|
22
|
+
const { t } = useI18n();
|
|
23
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
24
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
25
|
+
const [renameDraft, setRenameDraft] = useState('');
|
|
26
|
+
const [dragOver, setDragOver] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
// Drop folder ids that no longer exist from the collapsed set.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setCollapsed((prev) => {
|
|
31
|
+
const valid = new Set(folders.folders.map((f) => f.id));
|
|
32
|
+
const next = new Set([...prev].filter((id) => valid.has(id)));
|
|
33
|
+
return next.size === prev.size ? prev : next;
|
|
34
|
+
});
|
|
35
|
+
}, [folders.folders]);
|
|
36
|
+
|
|
37
|
+
const toggleCollapse = (id: string) =>
|
|
38
|
+
setCollapsed((prev) => {
|
|
39
|
+
const next = new Set(prev);
|
|
40
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
41
|
+
return next;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const startDrag = (payload: DragPayload) => (e: DragEvent) => {
|
|
45
|
+
e.dataTransfer.setData(DND_TYPE, JSON.stringify(payload));
|
|
46
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const allowDrop = (e: DragEvent) => {
|
|
50
|
+
if (e.dataTransfer.types.includes(DND_TYPE)) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
e.dataTransfer.dropEffect = 'move';
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const dropInto = (folderId: string | null) => (e: DragEvent) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
setDragOver(null);
|
|
60
|
+
const raw = e.dataTransfer.getData(DND_TYPE);
|
|
61
|
+
if (!raw) return;
|
|
62
|
+
let payload: DragPayload;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(raw) as DragPayload;
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (payload.kind === 'session') folders.moveSession(payload.name, folderId);
|
|
69
|
+
else folders.moveFolder(payload.id, folderId);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const commitFolderRename = () => {
|
|
73
|
+
if (renamingId && renameDraft.trim()) folders.renameFolder(renamingId, renameDraft.trim());
|
|
74
|
+
setRenamingId(null);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function renderLevel(parentId: string | null, depth: number): ReactNode {
|
|
78
|
+
const subFolders = folders.folders.filter((f) => f.parentId === parentId);
|
|
79
|
+
const subSessions = sessions.filter((s) => folders.folderOf(s.name) === parentId);
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{subFolders.map((f) => {
|
|
83
|
+
const isCollapsed = collapsed.has(f.id);
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
key={f.id}
|
|
87
|
+
className={`folder ${dragOver === f.id ? 'drop' : ''}`}
|
|
88
|
+
onDragOver={(e) => {
|
|
89
|
+
allowDrop(e);
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setDragOver(f.id);
|
|
92
|
+
}}
|
|
93
|
+
onDragLeave={() => setDragOver((p) => (p === f.id ? null : p))}
|
|
94
|
+
onDrop={dropInto(f.id)}
|
|
95
|
+
>
|
|
96
|
+
{renamingId === f.id ? (
|
|
97
|
+
<div className="folder-head" style={{ paddingLeft: 8 + depth * 14 }}>
|
|
98
|
+
<input
|
|
99
|
+
autoFocus
|
|
100
|
+
value={renameDraft}
|
|
101
|
+
onChange={(e) => setRenameDraft(e.target.value)}
|
|
102
|
+
onKeyDown={(e) => {
|
|
103
|
+
if (e.key === 'Enter') commitFolderRename();
|
|
104
|
+
else if (e.key === 'Escape') setRenamingId(null);
|
|
105
|
+
}}
|
|
106
|
+
onBlur={commitFolderRename}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
) : (
|
|
110
|
+
<div
|
|
111
|
+
className="folder-head"
|
|
112
|
+
style={{ paddingLeft: 8 + depth * 14 }}
|
|
113
|
+
draggable
|
|
114
|
+
onDragStart={startDrag({ kind: 'folder', id: f.id })}
|
|
115
|
+
onClick={() => toggleCollapse(f.id)}
|
|
116
|
+
>
|
|
117
|
+
<span className="caret">{isCollapsed ? '▸' : '▾'}</span>
|
|
118
|
+
<span className="folder-name" title={f.name}>
|
|
119
|
+
{isCollapsed ? '📁' : '📂'} {f.name}
|
|
120
|
+
</span>
|
|
121
|
+
<span className="folder-actions">
|
|
122
|
+
<button
|
|
123
|
+
title={t.newSubfolder}
|
|
124
|
+
onClick={(e) => {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
folders.addFolder(f.id, t.folderDefaultName);
|
|
127
|
+
setCollapsed((p) => {
|
|
128
|
+
const n = new Set(p);
|
|
129
|
+
n.delete(f.id);
|
|
130
|
+
return n;
|
|
131
|
+
});
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
+
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
title={t.renameFolder}
|
|
138
|
+
onClick={(e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
setRenameDraft(f.name);
|
|
141
|
+
setRenamingId(f.id);
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
✎
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
className="danger"
|
|
148
|
+
title={t.deleteFolder}
|
|
149
|
+
onClick={(e) => {
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
folders.deleteFolder(f.id);
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
✕
|
|
155
|
+
</button>
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
{!isCollapsed && <div className="folder-children">{renderLevel(f.id, depth + 1)}</div>}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
{subSessions.map((s) => (
|
|
164
|
+
<SessionRow
|
|
165
|
+
key={s.name}
|
|
166
|
+
targetId={targetId}
|
|
167
|
+
session={s}
|
|
168
|
+
nowMs={nowMs}
|
|
169
|
+
depth={depth}
|
|
170
|
+
selected={selection?.targetId === targetId && selection.session === s.name}
|
|
171
|
+
onSelect={() => select({ targetId, session: s.name })}
|
|
172
|
+
onRename={(next) => onRename(s.name, next)}
|
|
173
|
+
onKill={() => onKill(s.name)}
|
|
174
|
+
onDragStart={startDrag({ kind: 'session', name: s.name })}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const empty = folders.folders.length === 0 && sessions.length === 0;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
className={`tree ${dragOver === '__root__' ? 'drop-root' : ''}`}
|
|
186
|
+
onDragOver={(e) => {
|
|
187
|
+
allowDrop(e);
|
|
188
|
+
setDragOver('__root__');
|
|
189
|
+
}}
|
|
190
|
+
onDragLeave={() => setDragOver((p) => (p === '__root__' ? null : p))}
|
|
191
|
+
onDrop={dropInto(null)}
|
|
192
|
+
>
|
|
193
|
+
{empty && <div className="empty">{t.noSessions}</div>}
|
|
194
|
+
{renderLevel(null, 0)}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { FONT_LIMITS, useSettings, type Language, type Settings } from '../settings';
|
|
3
|
+
import { useI18n } from '../i18n';
|
|
4
|
+
|
|
5
|
+
function Stepper({
|
|
6
|
+
label,
|
|
7
|
+
value,
|
|
8
|
+
smaller,
|
|
9
|
+
larger,
|
|
10
|
+
onChange,
|
|
11
|
+
}: {
|
|
12
|
+
label: string;
|
|
13
|
+
value: number;
|
|
14
|
+
smaller: string;
|
|
15
|
+
larger: string;
|
|
16
|
+
onChange: (n: number) => void;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="stepper">
|
|
20
|
+
<span className="stepper-label">{label}</span>
|
|
21
|
+
<div className="stepper-controls">
|
|
22
|
+
<button onClick={() => onChange(value - 1)} disabled={value <= FONT_LIMITS.min} aria-label={smaller}>
|
|
23
|
+
−
|
|
24
|
+
</button>
|
|
25
|
+
<span className="stepper-value">{value}px</span>
|
|
26
|
+
<button onClick={() => onChange(value + 1)} disabled={value >= FONT_LIMITS.max} aria-label={larger}>
|
|
27
|
+
+
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Toggle({
|
|
35
|
+
label,
|
|
36
|
+
checked,
|
|
37
|
+
onChange,
|
|
38
|
+
}: {
|
|
39
|
+
label: string;
|
|
40
|
+
checked: boolean;
|
|
41
|
+
onChange: (v: boolean) => void;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<label className="toggle">
|
|
45
|
+
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
46
|
+
<span className="toggle-label">{label}</span>
|
|
47
|
+
</label>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SettingsButton() {
|
|
52
|
+
const { settings, setSetting, reset } = useSettings();
|
|
53
|
+
const { t } = useI18n();
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
|
|
56
|
+
const step =
|
|
57
|
+
(key: keyof Pick<Settings, 'sidebarFontSize' | 'terminalFontSize' | 'viewerFontSize'>) =>
|
|
58
|
+
(n: number) =>
|
|
59
|
+
setSetting(key, n);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="settings">
|
|
63
|
+
<button className="settings-gear" onClick={() => setOpen((v) => !v)} title={t.settings}>
|
|
64
|
+
⚙ {t.settings}
|
|
65
|
+
</button>
|
|
66
|
+
{open && (
|
|
67
|
+
<>
|
|
68
|
+
<div className="settings-backdrop" onClick={() => setOpen(false)} />
|
|
69
|
+
<div className="settings-panel" role="dialog">
|
|
70
|
+
<div className="settings-title">{t.language}</div>
|
|
71
|
+
<select
|
|
72
|
+
value={settings.language}
|
|
73
|
+
onChange={(e) => setSetting('language', e.target.value as Language)}
|
|
74
|
+
>
|
|
75
|
+
<option value="zh">{t.chinese}</option>
|
|
76
|
+
<option value="en">{t.english}</option>
|
|
77
|
+
</select>
|
|
78
|
+
<div className="settings-title">{t.fontSizes}</div>
|
|
79
|
+
<Stepper
|
|
80
|
+
label={t.sidebar}
|
|
81
|
+
value={settings.sidebarFontSize}
|
|
82
|
+
smaller={t.smaller}
|
|
83
|
+
larger={t.larger}
|
|
84
|
+
onChange={step('sidebarFontSize')}
|
|
85
|
+
/>
|
|
86
|
+
<Stepper
|
|
87
|
+
label={t.terminal}
|
|
88
|
+
value={settings.terminalFontSize}
|
|
89
|
+
smaller={t.smaller}
|
|
90
|
+
larger={t.larger}
|
|
91
|
+
onChange={step('terminalFontSize')}
|
|
92
|
+
/>
|
|
93
|
+
<Stepper
|
|
94
|
+
label={t.fileViewer}
|
|
95
|
+
value={settings.viewerFontSize}
|
|
96
|
+
smaller={t.smaller}
|
|
97
|
+
larger={t.larger}
|
|
98
|
+
onChange={step('viewerFontSize')}
|
|
99
|
+
/>
|
|
100
|
+
<div className="settings-title">{t.notifications}</div>
|
|
101
|
+
<Toggle
|
|
102
|
+
label={t.alertWhenAgent}
|
|
103
|
+
checked={settings.notifyAttention}
|
|
104
|
+
onChange={(v) => setSetting('notifyAttention', v)}
|
|
105
|
+
/>
|
|
106
|
+
<Toggle
|
|
107
|
+
label={t.playSound}
|
|
108
|
+
checked={settings.notifySound}
|
|
109
|
+
onChange={(v) => setSetting('notifySound', v)}
|
|
110
|
+
/>
|
|
111
|
+
<div className="settings-actions">
|
|
112
|
+
<button onClick={reset}>{t.reset}</button>
|
|
113
|
+
<button className="primary" onClick={() => setOpen(false)}>
|
|
114
|
+
{t.done}
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|