sshler 0.3.2__py3-none-any.whl

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.
sshler/static/term.js ADDED
@@ -0,0 +1,304 @@
1
+ (function () {
2
+ function getToken() {
3
+ if (window.sshlerToken) {
4
+ return window.sshlerToken;
5
+ }
6
+ const tokenMeta = document.querySelector('meta[name="sshler-token"]');
7
+ return tokenMeta ? tokenMeta.getAttribute("content") || "" : "";
8
+ }
9
+
10
+ function setupCommandButtons(ws) {
11
+ const commandMap = {
12
+ "scroll-mode": { type: "send", payload: "\u0002[" },
13
+ escape: { type: "send", payload: "\u001b" },
14
+ "ctrl-t": { type: "send", payload: "\u0014" },
15
+ "ctrl-c": { type: "send", payload: "\u0003" },
16
+ "split-h": { type: "send", payload: "\u0002%" },
17
+ "split-v": { type: "send", payload: "\u0002\"" },
18
+ "new-window": { type: "send", payload: "\u0002c" },
19
+ "rename-window": { type: "operation", op: "rename-window" },
20
+ "kill-pane": { type: "send", payload: "\u0002x" },
21
+ next: { type: "send", payload: "\u0002n" },
22
+ prev: { type: "send", payload: "\u0002p" },
23
+ detach: { type: "send", payload: "\u0002d" },
24
+ };
25
+
26
+ document
27
+ .querySelectorAll(".term-toolbar [data-command]")
28
+ .forEach((button) => {
29
+ button.addEventListener("click", () => {
30
+ const command = button.dataset.command;
31
+ const config = commandMap[command];
32
+ if (!config) {
33
+ return;
34
+ }
35
+ if (config.type === "send") {
36
+ ws.send(
37
+ JSON.stringify({ op: "send", data: config.payload }),
38
+ );
39
+ } else if (config.type === "operation" && config.op === "rename-window") {
40
+ const newName = prompt("Rename window to:");
41
+ if (newName) {
42
+ ws.send(
43
+ JSON.stringify({ op: "rename-window", target: newName }),
44
+ );
45
+ }
46
+ }
47
+ });
48
+ });
49
+ }
50
+
51
+ document.addEventListener("DOMContentLoaded", () => {
52
+ const root = document.querySelector("[data-term-root]");
53
+ if (!root) {
54
+ return;
55
+ }
56
+
57
+ const dirLabel = root.dataset.dirLabel || "";
58
+ if (dirLabel) {
59
+ document.title = `${dirLabel} — sshler`;
60
+ }
61
+
62
+ document.body.classList.add("term-view");
63
+ if (typeof window.sshlerSetFavicon === "function") {
64
+ window.sshlerSetFavicon("terminal");
65
+ }
66
+ window.addEventListener("beforeunload", () => {
67
+ document.body.classList.remove("term-view");
68
+ if (typeof window.sshlerSetFavicon === "function") {
69
+ window.sshlerSetFavicon("default");
70
+ }
71
+ });
72
+
73
+ const term = new Terminal({
74
+ cursorBlink: true,
75
+ convertEol: true,
76
+ scrollback: 10000,
77
+ fastScrollModifier: "shift",
78
+ fastScrollSensitivity: 5,
79
+ });
80
+ const fitAddon = new FitAddon.FitAddon();
81
+ term.loadAddon(fitAddon);
82
+ term.open(document.getElementById("term"));
83
+
84
+ // Fit immediately to get proper dimensions before creating WebSocket
85
+ // Use triple requestAnimationFrame to ensure layout is fully settled
86
+ let ws;
87
+ requestAnimationFrame(() => {
88
+ requestAnimationFrame(() => {
89
+ requestAnimationFrame(() => {
90
+ // Now the layout should be fully calculated
91
+ fitAddon.fit();
92
+
93
+ const url = new URL(window.location.href);
94
+ const host = url.searchParams.get("host") || root.dataset.host || "";
95
+ const directory = url.searchParams.get("dir") || root.dataset.directory || "/";
96
+ const session =
97
+ url.searchParams.get("session") || root.dataset.session || "default";
98
+ const wsProto = location.protocol === "https:" ? "wss://" : "ws://";
99
+ const token = getToken();
100
+
101
+ // Now use the fitted dimensions
102
+ const wsUrl =
103
+ wsProto +
104
+ location.host +
105
+ `/ws/term?host=${encodeURIComponent(host)}&dir=${encodeURIComponent(directory)}&session=${encodeURIComponent(session)}&cols=${term.cols}&rows=${term.rows}&token=${encodeURIComponent(token)}`;
106
+
107
+ ws = new WebSocket(wsUrl);
108
+ ws.binaryType = "arraybuffer";
109
+
110
+ setupWebSocket(ws, term, fitAddon);
111
+ });
112
+ });
113
+ });
114
+
115
+ function setupWebSocket(ws, term, fitAddon) {
116
+ const encoder = new TextEncoder();
117
+ const termToolbar = document.getElementById("term-toolbar");
118
+ const termWrapper = document.getElementById("term-wrapper");
119
+ const filePanel = document.getElementById("file-panel");
120
+ const fileBrowser = document.getElementById("file-browser");
121
+ const tabsContainer = document.getElementById("tmux-tabs");
122
+
123
+ let filePanelActive = false;
124
+ let filePanelLoaded = false;
125
+ let fileTabButton = null;
126
+ let latestWindows = [];
127
+
128
+ function sendResize() {
129
+ // Use requestAnimationFrame to ensure DOM is updated before fitting
130
+ requestAnimationFrame(() => {
131
+ fitAddon.fit();
132
+ if (ws.readyState === WebSocket.OPEN) {
133
+ ws.send(
134
+ JSON.stringify({ op: "resize", cols: term.cols, rows: term.rows }),
135
+ );
136
+ }
137
+ });
138
+ }
139
+
140
+ function activateTerminalView() {
141
+ if (!filePanelActive) {
142
+ return;
143
+ }
144
+ filePanelActive = false;
145
+ termToolbar.classList.remove("hidden");
146
+ termWrapper.classList.remove("hidden");
147
+ filePanel.classList.add("hidden");
148
+ if (fileTabButton) {
149
+ fileTabButton.classList.remove("active");
150
+ }
151
+ // Ensure terminal refits after panel visibility changes
152
+ requestAnimationFrame(() => {
153
+ fitAddon.fit();
154
+ });
155
+ }
156
+
157
+ function activateFileView() {
158
+ if (filePanelActive) {
159
+ return;
160
+ }
161
+ filePanelActive = true;
162
+ termToolbar.classList.add("hidden");
163
+ termWrapper.classList.add("hidden");
164
+ filePanel.classList.remove("hidden");
165
+ if (!filePanelLoaded && window.htmx) {
166
+ window.htmx.trigger(fileBrowser, "revealed");
167
+ filePanelLoaded = true;
168
+ }
169
+ if (fileTabButton) {
170
+ fileTabButton.classList.add("active");
171
+ }
172
+ }
173
+
174
+ function renderTabs(windows) {
175
+ latestWindows = windows || [];
176
+ if (!tabsContainer) {
177
+ return;
178
+ }
179
+ tabsContainer.innerHTML = "";
180
+
181
+ latestWindows.forEach((windowInfo) => {
182
+ const tab = document.createElement("button");
183
+ const isActive = windowInfo.active && !filePanelActive;
184
+ tab.className = "tmux-tab" + (isActive ? " active" : "");
185
+ const name = windowInfo.name || `#${windowInfo.index}`;
186
+ tab.textContent = `${windowInfo.index}: ${name}`;
187
+ tab.addEventListener("click", () => {
188
+ activateTerminalView();
189
+ ws.send(
190
+ JSON.stringify({
191
+ op: "select-window",
192
+ target: windowInfo.index,
193
+ }),
194
+ );
195
+ });
196
+ tabsContainer.appendChild(tab);
197
+ });
198
+
199
+ const separator = document.createElement("span");
200
+ separator.className = "tmux-separator";
201
+ separator.textContent = "|";
202
+ tabsContainer.appendChild(separator);
203
+
204
+ fileTabButton = document.createElement("button");
205
+ fileTabButton.className = "tmux-tab" + (filePanelActive ? " active" : "");
206
+ fileTabButton.textContent = "Files";
207
+ fileTabButton.addEventListener("click", () => {
208
+ if (filePanelActive) {
209
+ activateTerminalView();
210
+ } else {
211
+ activateFileView();
212
+ }
213
+ renderTabs(latestWindows);
214
+ });
215
+ tabsContainer.appendChild(fileTabButton);
216
+ }
217
+
218
+ ws.onopen = () => {
219
+ term.focus();
220
+ };
221
+
222
+ ws.onmessage = (event) => {
223
+ if (typeof event.data === "string") {
224
+ try {
225
+ const message = JSON.parse(event.data);
226
+ if (message.op === "windows") {
227
+ renderTabs(message.windows);
228
+ return;
229
+ }
230
+ } catch (err) {
231
+ term.write(event.data);
232
+ return;
233
+ }
234
+ term.write(event.data);
235
+ } else if (event.data instanceof ArrayBuffer) {
236
+ term.write(new Uint8Array(event.data));
237
+ }
238
+ };
239
+
240
+ ws.onclose = () => {
241
+ term.write("\r\n\u001b[31m[Connection closed — refresh to reconnect]\u001b[0m\r\n");
242
+ };
243
+
244
+ term.onData((data) => {
245
+ ws.send(encoder.encode(data));
246
+ });
247
+
248
+ term.attachCustomKeyEventHandler((ev) => {
249
+ if (ev.ctrlKey && ev.key && ev.key.toLowerCase() === "t") {
250
+ ws.send(JSON.stringify({ op: "send", data: "\u0014" }));
251
+ return false;
252
+ }
253
+ return true;
254
+ });
255
+
256
+ window.addEventListener("resize", sendResize);
257
+ window.addEventListener("focus", () => term.focus());
258
+ document.addEventListener("visibilitychange", () => {
259
+ if (!document.hidden) {
260
+ sendResize();
261
+ }
262
+ });
263
+
264
+ document.addEventListener("keydown", (event) => {
265
+ if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "b") {
266
+ event.preventDefault();
267
+ if (filePanelActive) {
268
+ activateTerminalView();
269
+ } else {
270
+ activateFileView();
271
+ }
272
+ renderTabs(latestWindows);
273
+ }
274
+ });
275
+
276
+ const termElement = document.getElementById("term");
277
+
278
+ termElement.addEventListener("contextmenu", async (event) => {
279
+ event.preventDefault();
280
+ const selection = term.getSelection();
281
+ if (selection) {
282
+ try {
283
+ await navigator.clipboard.writeText(selection);
284
+ term.clearSelection();
285
+ } catch (err) {
286
+ console.warn("Clipboard copy failed", err);
287
+ }
288
+ return;
289
+ }
290
+ try {
291
+ const text = await navigator.clipboard.readText();
292
+ if (text) {
293
+ ws.send(JSON.stringify({ op: "send", data: text }));
294
+ }
295
+ } catch (err) {
296
+ console.warn("Clipboard paste failed", err);
297
+ }
298
+ });
299
+
300
+ setupCommandButtons(ws);
301
+ renderTabs([]);
302
+ }
303
+ });
304
+ })();
@@ -0,0 +1,41 @@
1
+
2
+ <!doctype html>
3
+ <html>
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>sshler</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ <script src="https://unpkg.com/htmx.org@1.9.12"></script>
9
+ <meta name="sshler-token" content="{{ csrf_token }}">
10
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" id="favicon-link">
11
+ <link rel="stylesheet" href="/static/style.css">
12
+ <link rel="stylesheet" href="https://unpkg.com/prismjs/themes/prism-tomorrow.css" />
13
+ {% block head_extra %}{% endblock %}
14
+ <script defer src="https://unpkg.com/prismjs/prism.js"></script>
15
+ <script defer src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
16
+ <script defer src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
17
+ <script defer src="https://unpkg.com/prismjs/components/prism-json.min.js"></script>
18
+ <script defer src="https://unpkg.com/prismjs/components/prism-yaml.min.js"></script>
19
+ <script defer src="/static/base.js"></script>
20
+ </head>
21
+ <body>
22
+ <div id="toast-container"></div>
23
+ <header class="topbar">
24
+ <a class="brand" href="/boxes">sshler</a>
25
+ <span class="version-pill">{{ app_version }}</span>
26
+ <div class="spacer"></div>
27
+ <a class="link" href="/boxes" data-i18n="nav.boxes">Boxes</a>
28
+ <a class="link" href="/boxes/new" data-i18n="nav.addBox">Add Box</a>
29
+ <button id="docs-btn" class="link docs-btn" data-i18n="nav.docs">Docs</button>
30
+ <button id="lang-toggle" class="lang-toggle" title="Switch language / 言語を切り替え">
31
+ <span data-lang="en">EN</span>
32
+ <span data-lang="ja" class="hidden">JP</span>
33
+ </button>
34
+ </header>
35
+ <main class="container">
36
+ {% block content %}{% endblock %}
37
+ </main>
38
+ <div id="modal-container"></div>
39
+ {% block extra_scripts %}{% endblock %}
40
+ </body>
41
+ </html>
@@ -0,0 +1,53 @@
1
+
2
+ {% extends "base.html" %}
3
+ {% block content %}
4
+ <h1>{{ box.name }}</h1>
5
+ {% if box.source == 'local' %}
6
+ <div class="muted small">Local workspace • {{ box.user }}@{{ box.display_host }}</div>
7
+ {% elif box.source == 'ssh_config' %}
8
+ <div class="muted small">{{ box.user }}@{{ box.display_host }} :{{ box.port }}{% if box.resolved_host and box.resolved_host != box.display_host %} • resolves to {{ box.resolved_host }}{% endif %}</div>
9
+ <div class="muted tiny">Sourced from SSH config &mdash; overrides reset via Refresh.</div>
10
+ <div class="actions">
11
+ <button class="btn secondary small"
12
+ hx-post="/box/{{ box.name }}/refresh"
13
+ hx-swap="none"
14
+ hx-on::after-request="window.location.reload()">Refresh from SSH config</button>
15
+ </div>
16
+ {% else %}
17
+ <div class="muted small">{{ box.user }}@{{ box.display_host }} :{{ box.port }}</div>
18
+ {% endif %}
19
+ <div class="grid">
20
+ <div>
21
+ <h2>Favorites</h2>
22
+ {% if box.favorites %}
23
+ <details class="favlist">
24
+ <summary class="favorite-toggle">Show Favorites</summary>
25
+ <ul>
26
+ {% for f in box.favorites %}
27
+ <li>
28
+ <a href="/term?host={{ box.name }}&dir={{ f|urlencode }}" class="btn small" target="_blank" rel="noopener">Terminal here</a>
29
+ <span class="mono">{{ f }}</span>
30
+ <button class="icon-btn star active"
31
+ hx-post="/box/{{ box.name }}/fav?path={{ f|urlencode }}"
32
+ hx-swap="none"
33
+ aria-label="Remove favorite">★</button>
34
+ </li>
35
+ {% endfor %}
36
+ </ul>
37
+ </details>
38
+ {% else %}
39
+ <p class="muted">No favorites yet.</p>
40
+ {% endif %}
41
+ </div>
42
+ <div>
43
+ <h2>Browse</h2>
44
+ <div id="browser"
45
+ hx-get="/box/{{ box.name }}/ls?path={{ base_directory|urlencode }}"
46
+ hx-trigger="load"
47
+ hx-target="#browser"
48
+ hx-swap="innerHTML">
49
+ Loading…
50
+ </div>
51
+ </div>
52
+ </div>
53
+ {% endblock %}
@@ -0,0 +1,40 @@
1
+ <div id="docs-modal" class="modal">
2
+ <div class="modal-content">
3
+ <div class="modal-header">
4
+ <h2>sshler Quick Reference</h2>
5
+ <div class="modal-actions">
6
+ <button class="lang-btn" data-lang="en">English</button>
7
+ <button class="lang-btn" data-lang="ja">日本語</button>
8
+ <button class="modal-close">&times;</button>
9
+ </div>
10
+ </div>
11
+ <div class="modal-body">
12
+ <div class="lang-content" data-lang="en">
13
+ <ul>
14
+ <li><strong>Boxes:</strong> Imported from your SSH config or added manually. Refresh an SSH-config box to pick up alias changes.</li>
15
+ <li><strong>Favorites:</strong> Collapse/expand quick links to directories. They always open in a new terminal tab.</li>
16
+ <li><strong>Terminal:</strong> Toolbar on the left sends tmux shortcuts; click window tabs or the <em>Files</em> button to jump between tmux and the file explorer.</li>
17
+ <li><strong>File preview:</strong> Click <em>Preview</em> next to any file to open a read-only view with syntax highlighting.</li>
18
+ <li><strong>Edit:</strong> Click <em>Edit</em> for inline editing (files &lt; 256 KB). Press Save to write changes and return to preview mode.</li>
19
+ <li><strong>Delete:</strong> Delete button appears in file preview and directory listings. Confirmation required before deletion.</li>
20
+ <li><strong>Clipboard:</strong> Right-click the terminal to copy/paste; Ctrl+T is trapped and sent into tmux.</li>
21
+ <li><strong>Keyboard shortcuts:</strong> Ctrl+Shift+B toggles between terminal and file browser when in terminal view.</li>
22
+ <li><strong>Security:</strong> sshler binds to 127.0.0.1 by default. If you expose it beyond localhost, add TLS and authentication.</li>
23
+ </ul>
24
+ </div>
25
+ <div class="lang-content hidden" data-lang="ja">
26
+ <ul>
27
+ <li><strong>ボックス:</strong> SSH 設定から自動取り込み、または手動で追加できます。SSH 設定を変更した際は Refresh ボタンでエイリアスの変更を反映します。</li>
28
+ <li><strong>お気に入り:</strong> ディレクトリへのクイックリンクを折りたたみ表示。常に新しいターミナルタブで開きます。</li>
29
+ <li><strong>ターミナル:</strong> 左側のツールバーで tmux ショートカットを送信。ウィンドウタブまたは<em>Files</em>ボタンをクリックして tmux とファイルエクスプローラーを切り替えられます。</li>
30
+ <li><strong>ファイルプレビュー:</strong> <em>Preview</em>をクリックすると、シンタックスハイライト付きの読み取り専用ビューが開きます。</li>
31
+ <li><strong>編集:</strong> <em>Edit</em>をクリックすると、256 KB 未満のファイルをブラウザ内で編集できます。Save を押すと変更が書き込まれ、プレビューモードに戻ります。</li>
32
+ <li><strong>削除:</strong> ファイルプレビューとディレクトリ一覧に削除ボタンが表示されます。削除前に確認ダイアログが表示されます。</li>
33
+ <li><strong>クリップボード:</strong> ターミナル上で右クリックしてコピー/ペースト。Ctrl+T は tmux に送信されます。</li>
34
+ <li><strong>キーボードショートカット:</strong> ターミナル表示中に Ctrl+Shift+B でターミナルとファイルブラウザを切り替えられます。</li>
35
+ <li><strong>セキュリティ:</strong> デフォルトで 127.0.0.1 にバインドされます。localhost 以外に公開する場合は TLS と認証を追加してください。</li>
36
+ </ul>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
@@ -0,0 +1,30 @@
1
+ {% extends "base.html" %}
2
+ {% block head_extra %}
3
+ <link rel="stylesheet" href="https://unpkg.com/prismjs/themes/prism-tomorrow.css" />
4
+ <link rel="stylesheet" href="https://unpkg.com/codemirror@5.65.16/lib/codemirror.css" />
5
+ <script src="https://unpkg.com/codemirror@5.65.16/lib/codemirror.js"></script>
6
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/python/python.js"></script>
7
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/javascript/javascript.js"></script>
8
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/css/css.js"></script>
9
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/xml/xml.js"></script>
10
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/markdown/markdown.js"></script>
11
+ <script src="https://unpkg.com/codemirror@5.65.16/mode/shell/shell.js"></script>
12
+ <script src="https://unpkg.com/codemirror@5.65.16/addon/display/placeholder.js"></script>
13
+ {% endblock %}
14
+ {% block content %}
15
+ <div class="file-edit">
16
+ <div class="file-view__header">
17
+ <h1>{{ box.name }} — {{ path }}</h1>
18
+ <div class="actions">
19
+ <a class="btn secondary small" href="/box/{{ box.name }}">Back</a>
20
+ <button class="btn small" id="save-btn">Save</button>
21
+ </div>
22
+ </div>
23
+ <textarea id="editor" class="file-edit__textarea" data-path="{{ path }}">{{ content }}</textarea>
24
+ <div id="save-status" class="muted small"></div>
25
+ </div>
26
+ {% endblock %}
27
+
28
+ {% block extra_scripts %}
29
+ <script defer src="/static/file-edit.js"></script>
30
+ {% endblock %}
@@ -0,0 +1,31 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="file-view">
4
+ <div class="file-view__header">
5
+ <h1>{{ box.name }} — {{ path }}</h1>
6
+ <div class="file-view__actions">
7
+ <button id="back-btn" class="btn secondary small">Back</button>
8
+ <a class="btn small" href="/box/{{ box.name }}/edit?path={{ path|urlencode }}">Edit</a>
9
+ <button id="delete-btn" class="btn small danger"
10
+ data-box="{{ box.name }}"
11
+ data-path="{{ path }}"
12
+ data-parentdir="{{ parent_directory }}"
13
+ data-filename="{{ path.split('/')[-1] if '/' in path else path }}">Delete</button>
14
+ </div>
15
+ </div>
16
+ {% if image_data %}
17
+ <div class="image-preview">
18
+ <img src="data:{{ image_mime }};base64,{{ image_data }}" alt="Preview of {{ path }}">
19
+ </div>
20
+ {% elif image_too_large %}
21
+ <div class="alert error">Image exceeds the {{ image_limit_kb }} KB preview limit.</div>
22
+ {% endif %}
23
+ {% if content %}
24
+ <pre class="file-view__content"><code class="language-{{ syntax_class or 'markup' }}">{{ content }}</code></pre>
25
+ {% endif %}
26
+ </div>
27
+ {% endblock %}
28
+
29
+ {% block extra_scripts %}
30
+ <script defer src="/static/file-view.js"></script>
31
+ {% endblock %}
@@ -0,0 +1,49 @@
1
+
2
+ {% extends "base.html" %}
3
+ {% block content %}
4
+ <h1>Boxes</h1>
5
+ <p>Pick a box to browse and open a terminal. Hosts are imported from your SSH config and any custom boxes you add here.</p>
6
+ <ul class="cards">
7
+ {% for box in configuration.boxes %}
8
+ <li class="card {% if box.source == 'local' %}local-card{% elif box.source == 'ssh_config' %}ssh-card{% else %}custom-card{% endif %}">
9
+ <div class="title">{{ box.name }}</div>
10
+ {% if box.source == 'local' %}
11
+ <div class="mono">{{ box.user }}@{{ box.display_host }}</div>
12
+ <div class="muted small">Local workspace</div>
13
+ {% else %}
14
+ <div class="mono">{{ box.user }}@{{ box.display_host }} :{{ box.port }}</div>
15
+ <div class="muted small">{{ 'From SSH config' if box.source == 'ssh_config' else 'Custom box' }}</div>
16
+ {% endif %}
17
+ {% if box.resolved_host and box.resolved_host != box.display_host %}
18
+ <div class="muted tiny">resolves to {{ box.resolved_host }}</div>
19
+ {% endif %}
20
+ {% if box.favorites %}
21
+ <details class="favlist">
22
+ <summary class="favorite-toggle">Favorites</summary>
23
+ <ul>
24
+ {% for favorite in box.favorites %}
25
+ <li><a href="/term?host={{ box.name }}&dir={{ favorite|urlencode }}" target="_blank" rel="noopener">{{ favorite }}</a></li>
26
+ {% endfor %}
27
+ </ul>
28
+ </details>
29
+ {% endif %}
30
+ <div class="actions">
31
+ <a class="btn" href="/box/{{ box.name }}">Open</a>
32
+ {% if box.default_dir %}
33
+ <a class="btn secondary" href="/term?host={{ box.name }}&dir={{ box.default_dir|urlencode }}">Terminal</a>
34
+ {% endif %}
35
+ {% if box.source == 'ssh_config' %}
36
+ <button class="btn secondary small"
37
+ hx-post="/box/{{ box.name }}/refresh"
38
+ hx-swap="none"
39
+ hx-on::after-request="window.location.reload()">
40
+ Refresh
41
+ </button>
42
+ {% endif %}
43
+ </div>
44
+ </li>
45
+ {% endfor %}
46
+ </ul>
47
+
48
+ <div class="muted small">Config file: {{ configuration_path if configuration_path else "" }}</div>
49
+ {% endblock %}
@@ -0,0 +1,42 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <h1>Add Box</h1>
4
+ <p>Define a custom box. Fields you leave blank will rely on SSH defaults.</p>
5
+ <form method="post" class="form">
6
+ <label>Name
7
+ <input type="text" name="name" required>
8
+ </label>
9
+ <label>Host (hostname or IP)
10
+ <input type="text" name="host" placeholder="my-server.internal">
11
+ </label>
12
+ <label>User
13
+ <input type="text" name="user" placeholder="deploy">
14
+ </label>
15
+ <label>Port
16
+ <input type="number" name="port" value="22" min="1" max="65535">
17
+ </label>
18
+ <label>Key file
19
+ <input type="text" name="keyfile" placeholder="~/.ssh/id_ed25519">
20
+ </label>
21
+ <label>SSH alias (optional)
22
+ <input type="text" name="ssh_alias" placeholder="OpenSSH alias to resolve">
23
+ </label>
24
+ <label>Default directory
25
+ <input type="text" name="default_dir" placeholder="/home/user">
26
+ </label>
27
+ <label>Favorites (one per line)
28
+ <textarea name="favorites" rows="4" placeholder="/var/www
29
+ /srv"></textarea>
30
+ </label>
31
+ <label>Known hosts override
32
+ <input type="text" name="known_hosts" placeholder="ignore or path">
33
+ </label>
34
+ <label class="checkbox">
35
+ <input type="checkbox" name="agent" value="1"> Use SSH agent
36
+ </label>
37
+ <div class="form-actions">
38
+ <button class="btn" type="submit">Save</button>
39
+ <a class="btn secondary" href="/boxes">Cancel</a>
40
+ </div>
41
+ </form>
42
+ {% endblock %}