mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-14 20:33:39 +00:00
v2.3.1 (#833)
* feat: add host list sort * feat: fixed rdp truncating (taskbar invisible unless resizing window) and improved split screen system * feat: revamp conneciton persistance system to save to backend with a new connections panel to restore old connections and view current ones. Also added new user profile toggle to reopen all tabs (saves and loads from backend). Added user profile toggle for host tray click vs hover. * feat: added WOL button, added proper use of BASE_PATH, toggles/buttons in admin/user profile now are always visible regardless of sidebar width, duplicating hosts not adding jumphost/socks5, keepalive internal not mulitplying into seconds causing a keepalive error, and finally guacamole giving 1_0_0 and 1_1_0 errors * feat: add filter host button, improve alert system UI, save sidebar width to localstorage, and fix host toolbar row overflow (add host going off screen on small sidebar width) * feat: add pin rail toggle, fix command pallete toggle not working, fixed command pallete toggling when typing in a field, made file manager not uppercase, host manager custom ports not loading, guacd hosts made on >=2.2.1, fixed host tags toggling, added reorder snippet sfeature, made snippet folder clllapse and require confimration toggle work, removed file manager color toggle, and fixed macos not displaying GUI until switching to another app and coming back, and jump host servers failing. * feat: use blacksmith caching for docker compile and improve keepalive system for ssh to all match the same implementation and use the data from the host config instead of a predefined value * feat: reset host manager state if the form is left and remove file manager color logic from the removed toggle * feat: update electron version check to use new ui * feat: improve duplication system to proplery map all fields
This commit is contained in:
@@ -30,7 +30,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
|
||||
@@ -138,7 +138,14 @@ nginx -c /tmp/nginx/nginx.conf
|
||||
# Inject runtime BASE_PATH into frontend if configured
|
||||
if [ -n "$BASE_PATH" ]; then
|
||||
echo "Injecting BASE_PATH: $BASE_PATH"
|
||||
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$BASE_PATH\"|g" {} \;
|
||||
# Strip trailing slash for use as a path prefix
|
||||
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
||||
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
|
||||
# Patch sw.js static asset paths with the base path prefix
|
||||
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
||||
else
|
||||
# No base path - replace placeholder with empty string so paths stay absolute from root
|
||||
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__||g" {} \;
|
||||
fi
|
||||
|
||||
echo "Starting backend services..."
|
||||
|
||||
@@ -196,6 +196,24 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/open-tabs(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/user-preferences(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
@@ -185,6 +185,24 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/open-tabs(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/user-preferences(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
@@ -933,6 +933,7 @@ function createWindow() {
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -2602,6 +2603,9 @@ app.on("window-all-closed", () => {
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
@@ -12,8 +12,8 @@
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Termix" />
|
||||
<link rel="apple-touch-icon" href="/icons/512x512.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="icons/512x512.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Termix</title>
|
||||
<style>
|
||||
.hide-scrollbar {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
+6
-5
@@ -1,10 +1,11 @@
|
||||
const CACHE_NAME = "termix-static-v2";
|
||||
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
|
||||
const STATIC_ASSETS = [
|
||||
"/favicon.ico",
|
||||
"/icons/48x48.png",
|
||||
"/icons/128x128.png",
|
||||
"/icons/256x256.png",
|
||||
"/icons/512x512.png",
|
||||
`${BASE_PATH}/favicon.ico`,
|
||||
`${BASE_PATH}/icons/48x48.png`,
|
||||
`${BASE_PATH}/icons/128x128.png`,
|
||||
`${BASE_PATH}/icons/256x256.png`,
|
||||
`${BASE_PATH}/icons/512x512.png`,
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
||||
@@ -17,22 +17,41 @@ if (!fs.existsSync(filePath)) {
|
||||
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
const oldCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
||||
const newCheck =
|
||||
// Patch 1: version acceptance list
|
||||
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
||||
const newVersionCheck =
|
||||
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
|
||||
|
||||
if (content.includes(newCheck)) {
|
||||
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
|
||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
||||
|
||||
let patched = false;
|
||||
|
||||
if (!content.includes(newVersionCheck)) {
|
||||
if (!content.includes(oldVersionCheck)) {
|
||||
console.log("[patch-guacamole-lite] Version check target not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldVersionCheck, newVersionCheck);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!content.includes(newTimezone)) {
|
||||
if (!content.includes(oldTimezone)) {
|
||||
console.log("[patch-guacamole-lite] Timezone target not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldTimezone, newTimezone);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!content.includes(oldCheck)) {
|
||||
console.log("[patch-guacamole-lite] Target code not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
content = content.replace(oldCheck, newCheck);
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0",
|
||||
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with correct timezone handshake",
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1762,6 +1764,8 @@ app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
|
||||
@@ -450,8 +450,41 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_open_tabs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
tab_type TEXT NOT NULL,
|
||||
host_id INTEGER,
|
||||
label TEXT NOT NULL,
|
||||
tab_order INTEGER NOT NULL DEFAULT 0,
|
||||
backend_session_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
||||
databaseLogger.info("Open tabs cleared on startup", {
|
||||
operation: "db_init_open_tabs_cleanup",
|
||||
});
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear open tabs on startup", {
|
||||
operation: "db_init_open_tabs_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
|
||||
|
||||
@@ -632,3 +632,33 @@ export const apiKeys = sqliteTable("api_keys", {
|
||||
lastUsedAt: text("last_used_at"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
|
||||
export const userOpenTabs = sqliteTable("user_open_tabs", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tabType: text("tab_type").notNull(),
|
||||
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
label: text("label").notNull(),
|
||||
tabOrder: integer("tab_order").notNull().default(0),
|
||||
backendSessionId: text("backend_session_id"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const userPreferences = sqliteTable("user_preferences", {
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
reopenTabsOnLogin: integer("reopen_tabs_on_login", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { userOpenTabs } from "../db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sessionManager } from "../../ssh/terminal-session-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs:
|
||||
* get:
|
||||
* summary: Get all open tabs for the current user
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of open tabs ordered by tab_order.
|
||||
*/
|
||||
const TAB_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
|
||||
const tabs = db
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.userId, userId), sql`${userOpenTabs.updatedAt} > ${cutoff}`))
|
||||
.orderBy(userOpenTabs.tabOrder)
|
||||
.all();
|
||||
return res.json(tabs);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get open tabs", e, { operation: "get_open_tabs", userId });
|
||||
return res.status(500).json({ error: "Failed to get open tabs" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs:
|
||||
* post:
|
||||
* summary: Upsert a single open tab for the current user
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [id, tabType, label, tabOrder]
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* tabType:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* label:
|
||||
* type: string
|
||||
* tabOrder:
|
||||
* type: integer
|
||||
* backendSessionId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tab upserted successfully.
|
||||
*/
|
||||
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id, tabType, hostId, label, tabOrder, backendSessionId } = req.body as {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
};
|
||||
|
||||
if (!id || !tabType || !label) {
|
||||
return res.status(400).json({ error: "id, tabType, and label are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.select().from(userOpenTabs).where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))).all();
|
||||
if (existing.length > 0) {
|
||||
// Preserve existing backendSessionId when not explicitly provided
|
||||
const sessionId = backendSessionId !== undefined ? backendSessionId : existing[0].backendSessionId;
|
||||
db.update(userOpenTabs)
|
||||
.set({ tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: sessionId ?? null, updatedAt: now })
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(userOpenTabs).values({ id, userId, tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: backendSessionId ?? null, updatedAt: now }).run();
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to upsert open tab", e, { operation: "upsert_open_tab", userId, id });
|
||||
return res.status(500).json({ error: "Failed to upsert open tab" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs:
|
||||
* put:
|
||||
* summary: Bulk replace all open tabs for the current user
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tabs:
|
||||
* type: array
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tabs updated successfully.
|
||||
*/
|
||||
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { tabs } = req.body as {
|
||||
tabs: Array<{
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!Array.isArray(tabs)) {
|
||||
return res.status(400).json({ error: "tabs must be an array" });
|
||||
}
|
||||
|
||||
try {
|
||||
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
|
||||
if (tabs.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(userOpenTabs).values(
|
||||
tabs.map((t) => ({
|
||||
id: t.id,
|
||||
userId,
|
||||
tabType: t.tabType,
|
||||
hostId: t.hostId ?? null,
|
||||
label: t.label,
|
||||
tabOrder: t.tabOrder,
|
||||
backendSessionId: t.backendSessionId ?? null,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to sync open tabs", e, { operation: "sync_open_tabs", userId });
|
||||
return res.status(500).json({ error: "Failed to sync open tabs" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/{id}:
|
||||
* patch:
|
||||
* summary: Update a single open tab
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tab updated successfully.
|
||||
* 404:
|
||||
* description: Tab not found.
|
||||
*/
|
||||
router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = String(req.params.id);
|
||||
const updates = req.body as Partial<{
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId: string | null;
|
||||
}>;
|
||||
|
||||
try {
|
||||
const result = db
|
||||
.update(userOpenTabs)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.run();
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Tab not found" });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update open tab", e, { operation: "update_open_tab", userId, id });
|
||||
return res.status(500).json({ error: "Failed to update open tab" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/{id}:
|
||||
* delete:
|
||||
* summary: Delete a single open tab
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tab deleted successfully.
|
||||
*/
|
||||
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = String(req.params.id);
|
||||
|
||||
try {
|
||||
db.delete(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.run();
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to delete open tab", e, { operation: "delete_open_tab", userId, id });
|
||||
return res.status(500).json({ error: "Failed to delete open tab" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/active-sessions:
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active sessions.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* isConnected:
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
*/
|
||||
router.get("/active-sessions", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const sessions = sessionManager.getUserSessions(userId);
|
||||
return res.json(
|
||||
sessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", userId });
|
||||
return res.status(500).json({ error: "Failed to get active sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { userPreferences } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
* get:
|
||||
* summary: Get preferences for the current user
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User preferences.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* reopenTabsOnLogin:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.all();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.json({ reopenTabsOnLogin: false });
|
||||
}
|
||||
return res.json({ reopenTabsOnLogin: rows[0].reopenTabsOnLogin });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", userId });
|
||||
return res.status(500).json({ error: "Failed to get user preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
* put:
|
||||
* summary: Update preferences for the current user
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* reopenTabsOnLogin:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
*/
|
||||
router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { reopenTabsOnLogin } = req.body as { reopenTabsOnLogin?: boolean };
|
||||
|
||||
if (typeof reopenTabsOnLogin !== "boolean") {
|
||||
return res.status(400).json({ error: "reopenTabsOnLogin must be a boolean" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.all();
|
||||
|
||||
if (existing.length === 0) {
|
||||
db.insert(userPreferences).values({
|
||||
userId,
|
||||
reopenTabsOnLogin,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).run();
|
||||
} else {
|
||||
db.update(userPreferences)
|
||||
.set({ reopenTabsOnLogin, updatedAt: new Date().toISOString() })
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.run();
|
||||
}
|
||||
|
||||
return res.json({ success: true, reopenTabsOnLogin });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update user preferences", e, { operation: "update_user_preferences", userId });
|
||||
return res.status(500).json({ error: "Failed to update user preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
dashboardPreferences,
|
||||
opksshTokens,
|
||||
apiKeys,
|
||||
userOpenTabs,
|
||||
userPreferences,
|
||||
} from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -284,6 +286,8 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
.delete(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
|
||||
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
|
||||
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
|
||||
@@ -226,10 +226,17 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
|
||||
// Apply the same migration fallback used in host.ts GET routes.
|
||||
const ct = host.connectionType as string;
|
||||
const rdpRaw = !!host.enableRdp;
|
||||
const vncRaw = !!host.enableVnc;
|
||||
const telRaw = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
|
||||
const protocolEnabledMap: Record<string, boolean> = {
|
||||
rdp: !!host.enableRdp,
|
||||
vnc: !!host.enableVnc,
|
||||
telnet: !!host.enableTelnet,
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
|
||||
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
|
||||
};
|
||||
if (!protocolEnabledMap[connectionType]) {
|
||||
return res.status(400).json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
@@ -155,12 +156,50 @@ async function createJumpHostChain(
|
||||
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
tryKeyboard: true,
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -182,6 +221,25 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentClient!.forwardOut(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -312,9 +313,47 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
@@ -330,6 +369,25 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
@@ -627,6 +685,13 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
} catch {
|
||||
host.terminalConfig = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!host.enableDocker) {
|
||||
sshLogger.warn("Docker not enabled for host", {
|
||||
@@ -757,8 +822,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval:
|
||||
typeof host.terminalConfig?.keepaliveInterval === "number"
|
||||
? host.terminalConfig.keepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof host.terminalConfig?.keepaliveCountMax === "number"
|
||||
? host.terminalConfig.keepaliveCountMax
|
||||
: 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
@@ -327,9 +327,47 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
@@ -345,6 +383,25 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
@@ -917,6 +974,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
authType,
|
||||
sudoPassword: undefined as string | undefined,
|
||||
};
|
||||
let hostKeepaliveInterval: number | undefined;
|
||||
let hostKeepaliveCountMax: number | undefined;
|
||||
if (hostId && userId && !password && !sshKey) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
@@ -929,6 +988,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -957,6 +1018,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -980,11 +1043,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 5,
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 5000,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
hostId,
|
||||
ip,
|
||||
|
||||
@@ -251,9 +251,47 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 30000,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
@@ -269,6 +307,25 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
@@ -1493,8 +1550,8 @@ async function buildSshConfig(
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
@@ -79,13 +79,27 @@ class TerminalSessionManager {
|
||||
const tabSessions = userSessions.filter(
|
||||
(s) => s.tabInstanceId === tabInstanceId,
|
||||
);
|
||||
if (tabSessions.length > 0) {
|
||||
for (const existing of tabSessions) {
|
||||
const isLiveSession =
|
||||
existing.isConnected &&
|
||||
existing.sshStream != null &&
|
||||
!existing.sshStream.destroyed;
|
||||
if (isLiveSession) {
|
||||
// Don't destroy a live session (even if detached) — the caller should attach instead
|
||||
sshLogger.warn("Tab instance has live session, skipping duplicate create", {
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: existing.attachedWs !== null,
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
sshLogger.warn("Tab instance already has session, destroying old", {
|
||||
operation: "session_tab_duplicate_cleanup",
|
||||
existingSessionId: tabSessions[0].id,
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
});
|
||||
this.destroySession(tabSessions[0].id);
|
||||
this.destroySession(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +193,7 @@ class TerminalSessionManager {
|
||||
|
||||
const isDetached =
|
||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab = session.tabInstanceId === tabInstanceId;
|
||||
const isOriginalTab = (session.attachedTabInstanceId ?? session.tabInstanceId) === tabInstanceId;
|
||||
|
||||
if (
|
||||
!isDetached &&
|
||||
|
||||
+116
-4
@@ -373,8 +373,46 @@ async function createJumpHostChain(
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 30000,
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
@@ -390,6 +428,25 @@ async function createJumpHostChain(
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
@@ -1380,6 +1437,61 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tabInstanceId,
|
||||
);
|
||||
|
||||
// If createSession returned an existing live session (duplicate tabInstanceId),
|
||||
// close the newly-established SSH connection and attach this WS to the live session instead.
|
||||
const existingSession = sessionManager.getSession(currentSessionId);
|
||||
if (
|
||||
existingSession &&
|
||||
existingSession.sshStream &&
|
||||
!existingSession.sshStream.destroyed &&
|
||||
existingSession.sshConn !== sshConn
|
||||
) {
|
||||
sshLogger.info(
|
||||
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
|
||||
{
|
||||
operation: "terminal_reuse_existing_session",
|
||||
sessionId: currentSessionId,
|
||||
tabInstanceId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
try {
|
||||
sshConn?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
sshConn = null;
|
||||
|
||||
sshStream = existingSession.sshStream;
|
||||
sshConn = existingSession.sshConn;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
|
||||
|
||||
const buffered = sessionManager.getBuffer(existingSession);
|
||||
if (buffered) {
|
||||
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionCreated",
|
||||
sessionId: currentSessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionAttached",
|
||||
sessionId: currentSessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "Session reattached" }),
|
||||
);
|
||||
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
sshLogger.info("Terminal session created after SSH ready", {
|
||||
operation: "terminal_session_created",
|
||||
sessionId: currentSessionId,
|
||||
@@ -2170,10 +2282,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval
|
||||
: 30000,
|
||||
? hostKeepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
@@ -943,8 +943,8 @@ async function connectEndpointThroughSource(
|
||||
sock: endpointSock,
|
||||
username: tunnelConfig.endpointUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -1150,6 +1150,14 @@ async function resolveC2STunnelSource(
|
||||
socks5Username: resolvedHost.socks5Username,
|
||||
socks5Password: resolvedHost.socks5Password,
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain,
|
||||
keepaliveInterval:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveCountMax
|
||||
: 5,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1162,8 +1170,8 @@ async function connectC2SSourceClient(
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -1609,6 +1617,18 @@ async function connectSSHTunnel(
|
||||
keyType: resolvedHost.keyType,
|
||||
authMethod: resolvedHost.authType,
|
||||
};
|
||||
if (tunnelConfig.keepaliveInterval === undefined) {
|
||||
tunnelConfig.keepaliveInterval =
|
||||
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveInterval * 1000
|
||||
: 60000;
|
||||
}
|
||||
if (tunnelConfig.keepaliveCountMax === undefined) {
|
||||
tunnelConfig.keepaliveCountMax =
|
||||
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveCountMax
|
||||
: 5;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source host credentials", {
|
||||
@@ -1936,8 +1956,8 @@ async function connectSSHTunnel(
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -2192,11 +2212,11 @@ async function killRemoteTunnelByMarker(
|
||||
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
|
||||
@@ -365,6 +365,9 @@ export interface TunnelConfig {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
}
|
||||
|
||||
export interface C2STunnelPreset {
|
||||
|
||||
@@ -182,9 +182,12 @@ export type Tunnel = {
|
||||
|
||||
export type Tab = {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
type: TabType;
|
||||
label: string;
|
||||
host?: Host;
|
||||
openedAt: number;
|
||||
restoredSessionId?: string | null;
|
||||
terminalRef?: import("react").RefObject<{
|
||||
sendInput?: (data: string) => void;
|
||||
reconnect?: () => void;
|
||||
@@ -286,6 +289,7 @@ export type Snippet = {
|
||||
description?: string;
|
||||
content: string;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export const FOLDER_ICONS = [
|
||||
|
||||
+394
-20
@@ -30,9 +30,22 @@ import type {
|
||||
SplitMode,
|
||||
HostFolder,
|
||||
} from "@/types/ui-types";
|
||||
import { getSSHHosts, getUserInfo } from "@/main-axios";
|
||||
import { PANE_COUNTS } from "@/lib/theme";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getUserInfo,
|
||||
getOpenTabs,
|
||||
addOpenTab,
|
||||
deleteOpenTab,
|
||||
patchOpenTab,
|
||||
getActiveSessions,
|
||||
getUserPreferences,
|
||||
type UserPreferences,
|
||||
type OpenTabRecord,
|
||||
} from "@/main-axios";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
return {
|
||||
@@ -72,6 +85,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
jumpHosts: (h.jumpHosts ?? []).map((j) => ({
|
||||
hostId: String(j.hostId),
|
||||
})),
|
||||
serverTunnels: [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: h.terminalConfig as Host["terminalConfig"],
|
||||
@@ -80,6 +96,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,26 +142,62 @@ export function AppShell({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: "dashboard", type: "dashboard", label: t("nav.dashboard") },
|
||||
{
|
||||
id: "dashboard",
|
||||
instanceId: "dashboard",
|
||||
type: "dashboard",
|
||||
label: t("nav.dashboard"),
|
||||
openedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||
const [userPrefs, setUserPrefs] = useState<UserPreferences>({
|
||||
reopenTabsOnLogin: false,
|
||||
});
|
||||
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
|
||||
const [hostsLoaded, setHostsLoaded] = useState(false);
|
||||
// Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this
|
||||
const [tabsReady, setTabsReady] = useState(false);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>("none");
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
||||
Array(6).fill(null),
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>(
|
||||
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
||||
);
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const [focusedPaneIndex, setFocusedPaneIndex] = useState<number | null>(null);
|
||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
OpenTabRecord[]
|
||||
>([]);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(266);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 266;
|
||||
});
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_splitMode", splitMode);
|
||||
}, [splitMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
||||
}, [paneTabIds]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
||||
@@ -164,6 +217,11 @@ export function AppShell({
|
||||
}, []);
|
||||
|
||||
const lastShiftTime = useRef(0);
|
||||
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||
useState<boolean>(() => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -210,6 +268,7 @@ export function AppShell({
|
||||
snippets: "Snippets",
|
||||
history: "History",
|
||||
"split-screen": "Split Screen",
|
||||
connections: t("nav.connections"),
|
||||
"user-profile": "User Profile",
|
||||
"admin-settings": "Admin Settings",
|
||||
};
|
||||
@@ -217,15 +276,28 @@ export function AppShell({
|
||||
// Double-shift opens command palette
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "ShiftLeft") {
|
||||
if (e.code === "ShiftLeft" && !e.repeat) {
|
||||
const now = Date.now();
|
||||
if (now - lastShiftTime.current < 300)
|
||||
if (now - lastShiftTime.current < 300 && commandPaletteShortcutEnabled)
|
||||
setCommandPaletteOpen((prev) => !prev);
|
||||
lastShiftTime.current = now;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [commandPaletteShortcutEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
||||
};
|
||||
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"commandPaletteShortcutEnabledChanged",
|
||||
handler,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -288,6 +360,13 @@ export function AppShell({
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => setUserPrefs(prefs))
|
||||
.catch(() => {})
|
||||
.finally(() => setUserPrefsLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Load real hosts from API
|
||||
const loadHosts = useCallback(async () => {
|
||||
try {
|
||||
@@ -299,6 +378,7 @@ export function AppShell({
|
||||
// Keep empty state on error
|
||||
} finally {
|
||||
setHostsLoading(false);
|
||||
setHostsLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -336,19 +416,149 @@ export function AppShell({
|
||||
return () => window.removeEventListener("termix:open-tab", handle);
|
||||
}, [allHosts]);
|
||||
|
||||
const PERSISTENT_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
"files",
|
||||
"docker",
|
||||
"stats",
|
||||
"tunnel",
|
||||
];
|
||||
|
||||
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
||||
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
||||
const tabRestoreAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hostsLoaded || !userPrefsLoaded) return;
|
||||
if (tabRestoreAttemptedRef.current) return;
|
||||
tabRestoreAttemptedRef.current = true;
|
||||
|
||||
async function loadSavedTabs() {
|
||||
try {
|
||||
const [savedTabs, activeSessions] = await Promise.all([
|
||||
getOpenTabs(),
|
||||
getActiveSessions(),
|
||||
]);
|
||||
|
||||
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return;
|
||||
|
||||
const sessionByInstanceId = new Map(
|
||||
(Array.isArray(activeSessions) ? activeSessions : [])
|
||||
.filter((s) => s.tabInstanceId != null)
|
||||
.map((s) => [s.tabInstanceId, s]),
|
||||
);
|
||||
|
||||
if (userPrefs.reopenTabsOnLogin) {
|
||||
const hasPersistentTabs = tabs.some((t) =>
|
||||
PERSISTENT_TAB_TYPES.includes(t.type),
|
||||
);
|
||||
if (!hasPersistentTabs) {
|
||||
const restoredTabs: Tab[] = [];
|
||||
for (const saved of savedTabs as OpenTabRecord[]) {
|
||||
const host = saved.hostId
|
||||
? allHosts.find((h) => h.id === String(saved.hostId))
|
||||
: undefined;
|
||||
const hostlessTypes: TabType[] = ["dashboard", "tunnel"];
|
||||
if (!host && !hostlessTypes.includes(saved.tabType as TabType))
|
||||
continue;
|
||||
|
||||
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
||||
const tabId = host
|
||||
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
|
||||
: saved.id;
|
||||
const liveSession = sessionByInstanceId.get(saved.id);
|
||||
const restoredSessionId =
|
||||
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
||||
|
||||
restoredTabs.push({
|
||||
id: tabId,
|
||||
instanceId: saved.id,
|
||||
type: saved.tabType as TabType,
|
||||
label: saved.label,
|
||||
host,
|
||||
openedAt: new Date(saved.createdAt).getTime(),
|
||||
restoredSessionId,
|
||||
terminalRef:
|
||||
saved.tabType === "terminal" ? createRef() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (restoredTabs.length > 0) {
|
||||
setTabs((prev) => {
|
||||
const existingIds = new Set(prev.map((t) => t.id));
|
||||
const newTabs = restoredTabs.filter(
|
||||
(t) => !existingIds.has(t.id),
|
||||
);
|
||||
return newTabs.length > 0 ? [...prev, ...newTabs] : prev;
|
||||
});
|
||||
setActiveTabId(restoredTabs[0].id);
|
||||
}
|
||||
// Restored tabs are in the tab bar, not in background records
|
||||
}
|
||||
} else {
|
||||
// Not restoring to tab bar — keep as background records for ConnectionsPanel
|
||||
setBackgroundTabRecords(savedTabs as OpenTabRecord[]);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setTabsReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedTabs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostsLoaded, userPrefsLoaded]);
|
||||
|
||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const prevTabOrderRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!tabsReady) return;
|
||||
const persistable = tabs.filter((t) =>
|
||||
PERSISTENT_TAB_TYPES.includes(t.type),
|
||||
);
|
||||
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
||||
if (orderKey === prevTabOrderRef.current) return;
|
||||
prevTabOrderRef.current = orderKey;
|
||||
|
||||
if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current);
|
||||
orderSyncTimeoutRef.current = setTimeout(() => {
|
||||
persistable.forEach((t, i) => {
|
||||
patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {});
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (orderSyncTimeoutRef.current)
|
||||
clearTimeout(orderSyncTimeoutRef.current);
|
||||
};
|
||||
}, [tabs, tabsReady]);
|
||||
|
||||
// ─── Tab management ──────────────────────────────────────────────────────
|
||||
|
||||
const openTab = useCallback(function openTab(host: Host, type: TabType) {
|
||||
const openTab = useCallback(function openTab(
|
||||
host: Host,
|
||||
type: TabType,
|
||||
restore?: { instanceId: string; restoredSessionId: string | null },
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
const instanceId = restore?.instanceId ?? crypto.randomUUID();
|
||||
const openedAt = Date.now();
|
||||
const ref = type === "terminal" ? createRef() : undefined;
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
let finalLabel = host.name;
|
||||
setTabs((prev) => {
|
||||
const same = prev.filter(
|
||||
(t) =>
|
||||
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||
);
|
||||
const label =
|
||||
finalLabel =
|
||||
same.length === 0 ? host.name : `${host.name} (${same.length + 1})`;
|
||||
|
||||
// Retrofit the first duplicate's label to "(1)" if needed
|
||||
@@ -359,9 +569,31 @@ export function AppShell({
|
||||
)
|
||||
: prev;
|
||||
|
||||
return [...next, { id: tabId, type, label, host, terminalRef: ref }];
|
||||
return [
|
||||
...next,
|
||||
{
|
||||
id: tabId,
|
||||
instanceId,
|
||||
type,
|
||||
label: finalLabel,
|
||||
host,
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
setActiveTabId(tabId);
|
||||
|
||||
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
||||
addOpenTab({
|
||||
id: instanceId,
|
||||
tabType: type,
|
||||
hostId: host ? parseInt(host.id) : null,
|
||||
label: finalLabel,
|
||||
tabOrder: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function connectHost(host: Host, preferredType?: TabType) {
|
||||
@@ -411,17 +643,35 @@ export function AppShell({
|
||||
return;
|
||||
}
|
||||
const id = type;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
};
|
||||
setTabs((prev) => {
|
||||
if (prev.find((t) => t.id === id)) return prev;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
};
|
||||
return [...prev, { id, type, label: singletonLabels[type] ?? type }];
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
instanceId: id,
|
||||
type,
|
||||
label: singletonLabels[type] ?? type,
|
||||
openedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
setActiveTabId(id);
|
||||
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
||||
addOpenTab({
|
||||
id,
|
||||
tabType: type,
|
||||
hostId: null,
|
||||
label: singletonLabels[type] ?? type,
|
||||
tabOrder: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
@@ -429,6 +679,14 @@ export function AppShell({
|
||||
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
|
||||
|
||||
function doCloseTab(id: string) {
|
||||
const tabToClose = tabs.find((t) => t.id === id);
|
||||
if (
|
||||
tabToClose?.instanceId &&
|
||||
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
|
||||
) {
|
||||
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
||||
}
|
||||
|
||||
terminalRefs.current.delete(id);
|
||||
if (id === activeTabId) {
|
||||
const remaining = tabs.filter((t) => t.id !== id);
|
||||
@@ -436,11 +694,18 @@ export function AppShell({
|
||||
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
||||
);
|
||||
}
|
||||
setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p)));
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== id);
|
||||
if (next.length === 0)
|
||||
return [
|
||||
{ id: "dashboard", type: "dashboard", label: t("nav.dashboard") },
|
||||
{
|
||||
id: "dashboard",
|
||||
instanceId: "dashboard",
|
||||
type: "dashboard",
|
||||
label: t("nav.dashboard"),
|
||||
openedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
return next;
|
||||
});
|
||||
@@ -479,6 +744,53 @@ export function AppShell({
|
||||
doCloseTab(id);
|
||||
}
|
||||
|
||||
function splitTabQuick(tabId: string, mode: SplitMode) {
|
||||
setSplitMode(mode);
|
||||
setPaneTabIds(() => {
|
||||
const count = PANE_COUNTS[mode];
|
||||
const next: (string | null)[] = Array(6).fill(null);
|
||||
next[0] = tabId;
|
||||
// Fill remaining panes with other non-dashboard tabs in order
|
||||
let slot = 1;
|
||||
for (const tab of tabs) {
|
||||
if (slot >= count) break;
|
||||
if (tab.id !== tabId && tab.type !== "dashboard") {
|
||||
next[slot] = tab.id;
|
||||
slot++;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addTabToSplit(tabId: string) {
|
||||
setPaneTabIds((prev) => {
|
||||
// Remove from any current slot first
|
||||
const next = prev.map((p) => (p === tabId ? null : p));
|
||||
// Find first empty slot within the current pane count
|
||||
const count = PANE_COUNTS[splitMode];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!next[i]) {
|
||||
next[i] = tabId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeTabFromSplit(tabId: string) {
|
||||
setPaneTabIds((prev) => prev.map((p) => (p === tabId ? null : p)));
|
||||
}
|
||||
|
||||
function assignPane(paneIndex: number, tabId: string) {
|
||||
setPaneTabIds((prev) => {
|
||||
const next = prev.map((p) => (p === tabId ? null : p));
|
||||
next[paneIndex] = tabId;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Rail / sidebar ──────────────────────────────────────────────────────
|
||||
|
||||
function handleRailClick(view: RailView) {
|
||||
@@ -663,13 +975,62 @@ export function AppShell({
|
||||
setSplitMode={setSplitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
setPaneTabIds={setPaneTabIds}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "connections" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<ConnectionsPanel
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
allHosts={allHosts}
|
||||
backgroundTabRecords={backgroundTabRecords}
|
||||
onSwitchToTab={(tabId) => {
|
||||
setActiveTabId(tabId);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onCloseTab={closeTab}
|
||||
onReopenTab={(record, restoredSessionId) => {
|
||||
const host = record.hostId
|
||||
? allHosts.find((h) => h.id === String(record.hostId))
|
||||
: undefined;
|
||||
const hostlessTypes: TabType[] = ["tunnel"];
|
||||
if (!host && !hostlessTypes.includes(record.tabType as TabType))
|
||||
return;
|
||||
setBackgroundTabRecords((prev) =>
|
||||
prev.filter((r) => r.id !== record.id),
|
||||
);
|
||||
if (host) {
|
||||
const effectiveSessionId =
|
||||
restoredSessionId ?? record.backendSessionId ?? null;
|
||||
openTab(host, record.tabType as TabType, {
|
||||
instanceId: record.id,
|
||||
restoredSessionId: effectiveSessionId,
|
||||
});
|
||||
} else {
|
||||
openSingletonTab(record.tabType as TabType);
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onForgetBackground={(recordId) => {
|
||||
setBackgroundTabRecords((prev) =>
|
||||
prev.filter((r) => r.id !== recordId),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "user-profile" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<UserProfilePanel username={username} onLogout={onLogout} />
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
userPrefs={userPrefs}
|
||||
onPrefsChange={setUserPrefs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -721,6 +1082,10 @@ export function AppShell({
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
connectionCount={
|
||||
tabs.filter((t) => PERSISTENT_TAB_TYPES.includes(t.type)).length +
|
||||
backgroundTabRecords.length
|
||||
}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
profileDropdownOpen={profileDropdownOpen}
|
||||
@@ -783,10 +1148,16 @@ export function AppShell({
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode={splitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
@@ -802,8 +1173,11 @@ export function AppShell({
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={setFocusedPaneIndex}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
default: "bg-background text-foreground border-border",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
"text-destructive bg-background border-destructive/40 [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -38,21 +38,23 @@ export function SettingRow({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1">
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 ml-4 md:ml-8">{children}</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
jumpHosts: (h.jumpHosts ?? []).map((j) => ({
|
||||
hostId: String(j.hostId),
|
||||
})),
|
||||
serverTunnels: [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: h.terminalConfig as Host["terminalConfig"],
|
||||
@@ -88,6 +91,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import {
|
||||
@@ -28,21 +21,29 @@ interface AlertCardProps {
|
||||
const getAlertIcon = (type?: string) => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
return <AlertTriangle className="h-5 w-5 text-accent-brand" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
return <AlertCircle className="h-5 w-5 text-destructive" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
return <Info className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (priority?: string) => {
|
||||
const getAccentBarClass = (priority?: string, type?: string): string => {
|
||||
if (priority === "critical" || type === "error") return "bg-destructive";
|
||||
if (priority === "high" || type === "warning") return "bg-accent-brand";
|
||||
if (priority === "medium" || type === "success") return "bg-green-400";
|
||||
return "bg-border";
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (
|
||||
priority?: string,
|
||||
): "destructive" | "secondary" | "outline" => {
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
@@ -53,14 +54,15 @@ const getPriorityBadgeVariant = (priority?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeVariant = (type?: string) => {
|
||||
const getTypeBadgeVariant = (
|
||||
type?: string,
|
||||
): "destructive" | "secondary" | "outline" => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "success":
|
||||
return "default";
|
||||
case "info":
|
||||
default:
|
||||
return "outline";
|
||||
@@ -84,59 +86,61 @@ export function AlertCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-full border border-edge rounded-md !bg-elevated overflow-hidden">
|
||||
<div className={"h-1 w-full " + getAccentBarClass(alert.priority, alert.type)} />
|
||||
|
||||
<div className="flex items-start justify-between px-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<span className="text-sm font-semibold">{alert.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(alert.priority || alert.type) && (
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||
<Badge
|
||||
variant={getTypeBadgeVariant(alert.type)}
|
||||
className={alert.type === "success" ? "text-green-400" : undefined}
|
||||
>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
)}
|
||||
|
||||
<div className="mx-3 mb-3 border border-edge rounded-md px-3 py-3 !bg-canvas">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDismiss}>
|
||||
{t("common.dismiss")}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-1.5 text-accent-brand border-accent-brand/30 hover:border-accent-brand/60"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,53 +119,46 @@ export function AlertManager({
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
alerts.forEach((alert) => {
|
||||
const priority = alert.priority || "low";
|
||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||
});
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||
<div className="relative w-full max-w-2xl mx-4">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 z-[99999]">
|
||||
<div className="w-full max-w-2xl mx-4 flex flex-col gap-2">
|
||||
{hasMultipleAlerts && (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
|
||||
{hasMultipleAlerts && (
|
||||
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
<div className="border border-destructive/50 rounded-md px-3 py-2 text-destructive text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -96,11 +96,6 @@ interface FileManagerGridProps {
|
||||
}
|
||||
|
||||
const getFileTypeColor = (file: FileItem): string => {
|
||||
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
|
||||
if (!colorEnabled) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
return "text-red-400";
|
||||
}
|
||||
@@ -985,7 +980,7 @@ export function FileManagerGrid({
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="text-[11px] font-bold uppercase tracking-tight text-center truncate w-full px-1"
|
||||
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1"
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
@@ -1108,7 +1103,7 @@ export function FileManagerGrid({
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="font-bold truncate uppercase tracking-tight"
|
||||
className="font-bold truncate tracking-tight"
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
|
||||
@@ -373,6 +373,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) client.sendSize(w, h);
|
||||
}
|
||||
rescaleDisplay(false);
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
@@ -517,7 +518,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const w = Math.round(rect.width);
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
|
||||
if (w > 0 && h > 0) {
|
||||
clientRef.current.sendSize(w, h);
|
||||
rescaleDisplay(true);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
@@ -527,7 +531,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [rescaleDisplay]);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
|
||||
@@ -96,6 +96,7 @@ function resolveTermixThemeColors(activeTheme: string, appTheme: string) {
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -231,6 +232,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const isAttachingSessionRef = useRef<boolean>(false);
|
||||
// Consumed on first connectToHost call so retries don't re-attempt a stale session
|
||||
const pendingRestoredSessionIdRef = useRef<string | null>(hostConfig.restoredSessionId ?? null);
|
||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||
sessions: Array<{
|
||||
name: string;
|
||||
@@ -254,6 +257,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const isReconnectingRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const wasConnectedRef = useRef(false);
|
||||
const wasSessionExpiredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmountingRef.current = false;
|
||||
@@ -685,8 +689,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
}
|
||||
const tabId = hostConfig.id ?? "default";
|
||||
localStorage.removeItem(`termix_session_${tabId}`);
|
||||
sessionIdRef.current = null;
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
@@ -983,23 +985,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
currentHostIdRef.current = hostConfig.id;
|
||||
currentHostConfigRef.current = hostConfig;
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
const tabId = hostConfig.instanceId
|
||||
? `${hostConfig.id}_${hostConfig.instanceId}`
|
||||
: `${hostConfig.id}_${Date.now()}`;
|
||||
const savedSessionId = persistenceEnabled
|
||||
? localStorage.getItem(`termix_session_${tabId}`)
|
||||
: null;
|
||||
if (savedSessionId) {
|
||||
sessionIdRef.current = savedSessionId;
|
||||
// Consume the pending restored session ID once; retries get null so they create fresh connections
|
||||
const restoredSessionId = pendingRestoredSessionIdRef.current;
|
||||
pendingRestoredSessionIdRef.current = null;
|
||||
|
||||
if (restoredSessionId) {
|
||||
sessionIdRef.current = restoredSessionId;
|
||||
isAttachingSessionRef.current = true;
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "attachSession",
|
||||
data: {
|
||||
sessionId: savedSessionId,
|
||||
sessionId: restoredSessionId,
|
||||
cols,
|
||||
rows,
|
||||
tabInstanceId: hostConfig.instanceId,
|
||||
@@ -1484,12 +1482,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
} else if (msg.type === "sessionCreated") {
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (persistenceEnabled && hostConfig.instanceId) {
|
||||
const tabId = `${hostConfig.id}_${hostConfig.instanceId}`;
|
||||
localStorage.setItem(`termix_session_${tabId}`, msg.sessionId);
|
||||
if (hostConfig.instanceId) {
|
||||
import("@/main-axios").then(({ patchOpenTab }) => {
|
||||
patchOpenTab(hostConfig.instanceId!, { backendSessionId: msg.sessionId }).catch(() => {});
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "sessionAttached") {
|
||||
isAttachingSessionRef.current = false;
|
||||
@@ -1520,22 +1516,18 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
} else if (msg.type === "sessionExpired") {
|
||||
isAttachingSessionRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
if (hostConfig.instanceId) {
|
||||
const tabId = `${hostConfig.id}_${hostConfig.instanceId}`;
|
||||
localStorage.removeItem(`termix_session_${tabId}`);
|
||||
}
|
||||
sessionIdRef.current = null;
|
||||
|
||||
wasSessionExpiredRef.current = true;
|
||||
if (hostConfig.instanceId) {
|
||||
import("@/main-axios").then(({ patchOpenTab }) => {
|
||||
patchOpenTab(hostConfig.instanceId!, { backendSessionId: null }).catch(() => {});
|
||||
});
|
||||
}
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
} else if (msg.type === "sessionTakenOver") {
|
||||
if (sessionIdRef.current && hostConfig.instanceId) {
|
||||
const tabId = `${hostConfig.id}_${hostConfig.instanceId}`;
|
||||
localStorage.removeItem(`termix_session_${tabId}`);
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
sessionIdRef.current = null;
|
||||
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
@@ -1631,6 +1623,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
totpTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (wasSessionExpiredRef.current) {
|
||||
wasSessionExpiredRef.current = false;
|
||||
const cols = terminal?.cols || 80;
|
||||
const rows = terminal?.rows || 24;
|
||||
connectToHost(cols, rows);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.warn(
|
||||
"[WebSocket] Abnormal closure detected - attempting reconnection",
|
||||
@@ -2164,18 +2164,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !==
|
||||
"false";
|
||||
if (
|
||||
!persistenceEnabled &&
|
||||
sessionIdRef.current &&
|
||||
currentInstanceId
|
||||
) {
|
||||
const tabId = `${currentHostId}_${currentInstanceId}`;
|
||||
localStorage.removeItem(`termix_session_${tabId}`);
|
||||
}
|
||||
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
+77
-10
@@ -153,6 +153,7 @@
|
||||
"snippets": "Snippets",
|
||||
"hostManager": "Host Manager",
|
||||
"credentials": "Credentials",
|
||||
"connections": "Connections",
|
||||
"roleAdministrator": "Administrator",
|
||||
"roleUser": "User"
|
||||
},
|
||||
@@ -342,7 +343,7 @@
|
||||
"fastScrollSensitivityLabel": "Fast Scroll Sensitivity",
|
||||
"moshCommandLabel": "Mosh Command",
|
||||
"startupSnippetLabel": "Startup Snippet",
|
||||
"keepaliveIntervalLabel": "Keepalive Interval",
|
||||
"keepaliveIntervalLabel": "Keepalive Interval (seconds)",
|
||||
"maxKeepaliveMisses": "Max Keepalive Misses",
|
||||
"tunnelSettings": "Tunnel Settings",
|
||||
"enableTunneling": "Enable Tunneling",
|
||||
@@ -464,6 +465,9 @@
|
||||
"vncUrlCopied": "VNC URL copied",
|
||||
"telnetUrlCopied": "Telnet URL copied",
|
||||
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
|
||||
"wakeOnLanAction": "Wake on LAN",
|
||||
"wakeOnLanSuccess": "Magic packet sent to {{name}}",
|
||||
"wakeOnLanError": "Failed to send magic packet",
|
||||
"cloneHostAction": "Clone Host",
|
||||
"copyAddress": "Copy Address",
|
||||
"copyLink": "Copy Link",
|
||||
@@ -564,6 +568,38 @@
|
||||
"noHostsMatchSearch": "No hosts match your search",
|
||||
"hostNotFound": "Host not found",
|
||||
"searchHosts": "Search hosts...",
|
||||
"sortHosts": "Sort Hosts",
|
||||
"sortDefault": "Default Order",
|
||||
"sortNameAsc": "Name (A → Z)",
|
||||
"sortNameDesc": "Name (Z → A)",
|
||||
"sortIpAsc": "IP Address (Asc)",
|
||||
"sortIpDesc": "IP Address (Desc)",
|
||||
"sortOnlineFirst": "Online First",
|
||||
"sortOfflineFirst": "Offline First",
|
||||
"sortPinnedFirst": "Pinned First",
|
||||
"filterHosts": "Filter Hosts",
|
||||
"filterClearAll": "Clear Filters",
|
||||
"filterStatusGroup": "Status",
|
||||
"filterOnline": "Online",
|
||||
"filterOffline": "Offline",
|
||||
"filterPinned": "Pinned",
|
||||
"filterAuthGroup": "Auth Type",
|
||||
"filterAuthPassword": "Password",
|
||||
"filterAuthKey": "SSH Key",
|
||||
"filterAuthCredential": "Credential",
|
||||
"filterAuthNone": "None",
|
||||
"filterAuthOpkssh": "OPKSSH",
|
||||
"filterProtocolGroup": "Protocol",
|
||||
"filterProtocolSsh": "SSH",
|
||||
"filterProtocolRdp": "RDP",
|
||||
"filterProtocolVnc": "VNC",
|
||||
"filterProtocolTelnet": "Telnet",
|
||||
"filterFeaturesGroup": "Features",
|
||||
"filterFeatureTerminal": "Terminal",
|
||||
"filterFeatureFileManager": "File Manager",
|
||||
"filterFeatureTunnel": "Tunnel",
|
||||
"filterFeatureDocker": "Docker",
|
||||
"filterTagsGroup": "Tags",
|
||||
"addHost": "Add Host",
|
||||
"shareHost": "Share Host",
|
||||
"shareHostTitle": "Share: {{name}}",
|
||||
@@ -741,7 +777,28 @@
|
||||
},
|
||||
"splitScreen": {
|
||||
"paneEmpty": "Pane {{index}} - empty",
|
||||
"noTabAssigned": "No tab assigned"
|
||||
"noTabAssigned": "No tab assigned",
|
||||
"focusedPane": "Active pane"
|
||||
},
|
||||
"connections": {
|
||||
"noConnections": "No connections",
|
||||
"noConnectionsDesc": "Open a terminal, file manager, or remote desktop to see connections here",
|
||||
"connectedFor": "Connected for {{duration}}",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"closeTab": "Close tab",
|
||||
"closeConnection": "Close connection",
|
||||
"forgetTab": "Forget",
|
||||
"removeBackground": "Remove",
|
||||
"reconnect": "Reconnect",
|
||||
"reopenTab": "Reopen",
|
||||
"sectionOpen": "Open",
|
||||
"sectionBackground": "Background",
|
||||
"backgroundDesc": "Sessions persist for 30 minutes after disconnect and can be reconnected.",
|
||||
"persisted": "Persisted in background",
|
||||
"expiresIn": "Expires in {{duration}}",
|
||||
"search": "Search connections...",
|
||||
"noSearchResults": "No connections match your search"
|
||||
},
|
||||
"guacamole": {
|
||||
"connecting": "Connecting to {{type}} session...",
|
||||
@@ -1798,11 +1855,17 @@
|
||||
"selectLayoutHint": "Choose how many panes to display",
|
||||
"panesTitle": "Panes",
|
||||
"openTabsTitle": "Open Tabs",
|
||||
"dragTabsHint": "Drag tabs into panes above",
|
||||
"dragTabsHint": "Drag tabs into panes above, or use Quick Assign",
|
||||
"dropHere": "Drop here",
|
||||
"emptyPane": "Empty",
|
||||
"dashboard": "Dashboard",
|
||||
"clearSplitScreen": "Clear Split Screen"
|
||||
"clearSplitScreen": "Clear Split Screen",
|
||||
"quickAssign": "Quick Assign",
|
||||
"alreadyAssigned": "Pane {{index}}",
|
||||
"splitTab": "Split Tab",
|
||||
"addToSplit": "Add to Split",
|
||||
"removeFromSplit": "Remove from Split",
|
||||
"assignToPane": "Assign to pane"
|
||||
},
|
||||
"snippets": {
|
||||
"createSnippetTitle": "Create Snippet",
|
||||
@@ -1856,6 +1919,8 @@
|
||||
"folderDeleteFailed": "Failed to delete folder",
|
||||
"folderEditSuccess": "Folder updated successfully",
|
||||
"folderEditFailed": "Failed to update folder",
|
||||
"confirmRunMessage": "Run \"{{name}}\"?",
|
||||
"confirmRunButton": "Run",
|
||||
"runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)",
|
||||
"copySuccess": "Copied \"{{name}}\" to clipboard",
|
||||
"shareTitle": "Share Snippet",
|
||||
@@ -1870,7 +1935,8 @@
|
||||
"currentAccess": "Current Access",
|
||||
"shareLoadError": "Failed to load share data",
|
||||
"loading": "Loading...",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"reorderFailed": "Failed to save snippet order"
|
||||
},
|
||||
"userProfile": {
|
||||
"sectionAccount": "Account",
|
||||
@@ -1897,9 +1963,6 @@
|
||||
"themeLabel": "Theme",
|
||||
"fontSizeLabel": "Font Size",
|
||||
"accentColorLabel": "Accent Color",
|
||||
"settingsFileManager": "File Manager",
|
||||
"fileColorCoding": "File Color Coding",
|
||||
"fileColorCodingDesc": "Color-code files by type",
|
||||
"settingsTerminal": "Terminal",
|
||||
"commandAutocomplete": "Command Autocomplete",
|
||||
"commandAutocompleteDesc": "Show autocomplete while typing",
|
||||
@@ -1909,13 +1972,17 @@
|
||||
"syntaxHighlightingDesc": "Highlight terminal output",
|
||||
"commandPalette": "Command Palette",
|
||||
"commandPaletteDesc": "Enable keyboard shortcut",
|
||||
"sessionPersistence": "Session Persistence",
|
||||
"sessionPersistenceDesc": "Keep sessions between reconnects",
|
||||
"reopenTabsOnLogin": "Reopen Tabs on Login",
|
||||
"reopenTabsOnLoginDesc": "Restore your open tabs when you log in or refresh the page, even from another device",
|
||||
"confirmTabClose": "Confirm Tab Close",
|
||||
"confirmTabCloseDesc": "Ask before closing terminal tabs",
|
||||
"settingsSidebar": "Sidebar",
|
||||
"showHostTags": "Show Host Tags",
|
||||
"showHostTagsDesc": "Display tags in host list",
|
||||
"hostTrayOnClick": "Click to Expand Host Actions",
|
||||
"hostTrayOnClickDesc": "Require a click to expand host actions instead of hover",
|
||||
"pinAppRail": "Pin App Rail",
|
||||
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
||||
"settingsSnippets": "Snippets",
|
||||
"foldersCollapsed": "Folders Collapsed",
|
||||
"foldersCollapsedDesc": "Collapse folders by default",
|
||||
|
||||
+92
-1
@@ -3968,7 +3968,7 @@ export async function reorderSnippets(
|
||||
updates: Array<{ id: number; order: number; folder?: string }>,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/reorder", {
|
||||
const response = await authApi.put("/snippets/reorder", {
|
||||
snippets: updates,
|
||||
});
|
||||
return response.data;
|
||||
@@ -4928,3 +4928,94 @@ export async function saveDashboardPreferences(
|
||||
const response = await dashboardApi.post("/dashboard/preferences", layout);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenTabRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
tabType: string;
|
||||
hostId: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OpenTabSyncPayload {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenTabUpsertPayload {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
|
||||
const response = await authApi.get("/open-tabs");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function syncOpenTabs(tabs: OpenTabSyncPayload[]): Promise<void> {
|
||||
await authApi.put("/open-tabs", { tabs });
|
||||
}
|
||||
|
||||
export async function deleteOpenTab(instanceId: string): Promise<void> {
|
||||
await authApi.delete(`/open-tabs/${instanceId}`);
|
||||
}
|
||||
|
||||
export async function patchOpenTab(
|
||||
instanceId: string,
|
||||
updates: Partial<Pick<OpenTabRecord, "label" | "tabOrder" | "backendSessionId">>,
|
||||
): Promise<void> {
|
||||
await authApi.patch(`/open-tabs/${instanceId}`, updates);
|
||||
}
|
||||
|
||||
export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
||||
await authApi.post("/open-tabs", tab);
|
||||
}
|
||||
|
||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
const response = await authApi.get("/open-tabs/active-sessions");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
const response = await authApi.get("/user-preferences");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveUserPreferences(
|
||||
prefs: Partial<UserPreferences>,
|
||||
): Promise<void> {
|
||||
await authApi.put("/user-preferences", prefs);
|
||||
}
|
||||
|
||||
+105
-5
@@ -267,17 +267,32 @@ function RowDivider({
|
||||
function PaneHeader({
|
||||
tab,
|
||||
paneIndex,
|
||||
isFocused,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
isFocused: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2.5 h-7 shrink-0 bg-sidebar border-b border-border text-xs font-medium text-muted-foreground select-none">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 h-7 shrink-0 border-b text-xs font-medium select-none transition-colors ${
|
||||
isFocused
|
||||
? "bg-accent-brand/10 border-accent-brand/40 text-accent-brand"
|
||||
: "bg-sidebar border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isFocused && (
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent-brand shrink-0" />
|
||||
)}
|
||||
{tab ? (
|
||||
<>
|
||||
<span className="opacity-60">{tabIcon(tab.type)}</span>
|
||||
<span className="truncate text-foreground">
|
||||
<span className={isFocused ? "text-accent-brand" : "opacity-60"}>
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate ${isFocused ? "text-accent-brand font-semibold" : "text-foreground"}`}
|
||||
>
|
||||
{tab.type === "dashboard" ? "Dashboard" : tab.label}
|
||||
</span>
|
||||
</>
|
||||
@@ -309,13 +324,21 @@ const Pane = memo(function Pane({
|
||||
tab,
|
||||
paneIndex,
|
||||
isDragging,
|
||||
isFocused,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
isDragging: boolean;
|
||||
isFocused: boolean;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const contentRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
onPaneContentRef?.(paneIndex, el);
|
||||
@@ -324,14 +347,37 @@ const Pane = memo(function Pane({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col w-full h-full min-w-0 min-h-0 overflow-hidden">
|
||||
<PaneHeader tab={tab} paneIndex={paneIndex} />
|
||||
<div
|
||||
className={`relative flex flex-col w-full h-full min-w-0 min-h-0 overflow-hidden transition-colors ${
|
||||
isFocused ? "ring-1 ring-inset ring-accent-brand/30" : ""
|
||||
} ${isDragOver ? "ring-2 ring-inset ring-accent-brand" : ""}`}
|
||||
onClick={() => onPaneClick?.(paneIndex)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const tabId = e.dataTransfer.getData("text/plain");
|
||||
if (tabId) onAssignPane?.(paneIndex, tabId);
|
||||
}}
|
||||
>
|
||||
<PaneHeader tab={tab} paneIndex={paneIndex} isFocused={isFocused} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden relative">
|
||||
{tab ? (
|
||||
<div ref={contentRef} className="absolute inset-0" />
|
||||
) : (
|
||||
<EmptyPane />
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-accent-brand/10 border-2 border-dashed border-accent-brand pointer-events-none">
|
||||
<span className="text-xs font-medium text-accent-brand">
|
||||
Drop to assign
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-10" style={{ cursor: "inherit" }} />
|
||||
@@ -350,9 +396,12 @@ const Row = memo(function Row({
|
||||
paneTabIds,
|
||||
tabs,
|
||||
isDragging,
|
||||
focusedPaneIndex,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
rowIdx: number;
|
||||
paneIndices: number[];
|
||||
@@ -361,6 +410,7 @@ const Row = memo(function Row({
|
||||
paneTabIds: (string | null)[];
|
||||
tabs: Tab[];
|
||||
isDragging: boolean;
|
||||
focusedPaneIndex: number | null;
|
||||
onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void;
|
||||
onColDividerTouch: (
|
||||
e: React.TouchEvent,
|
||||
@@ -368,6 +418,8 @@ const Row = memo(function Row({
|
||||
colIdx: number,
|
||||
) => void;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const widths = colWidths ?? [];
|
||||
return (
|
||||
@@ -386,7 +438,10 @@ const Row = memo(function Row({
|
||||
tab={tab}
|
||||
paneIndex={pIdx}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === pIdx}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
{ci < paneIndices.length - 1 && (
|
||||
@@ -408,14 +463,20 @@ export const SplitView = memo(function SplitView({
|
||||
tabs,
|
||||
paneTabIds,
|
||||
splitMode,
|
||||
focusedPaneIndex,
|
||||
onTerminalResize,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
paneTabIds: (string | null)[];
|
||||
splitMode: SplitMode;
|
||||
focusedPaneIndex?: number | null;
|
||||
onTerminalResize?: () => void;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
rowSizes,
|
||||
@@ -464,9 +525,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -480,7 +544,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(0)}
|
||||
paneIndex={0}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 0}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<ColDivider
|
||||
@@ -496,7 +563,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 1}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<RowDivider
|
||||
@@ -511,7 +581,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 2}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,7 +605,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(0)}
|
||||
paneIndex={0}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 0}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<ColDivider
|
||||
@@ -544,7 +620,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 1}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +636,10 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 2}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,9 +655,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
@@ -589,9 +674,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -606,9 +694,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
@@ -622,9 +713,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -639,9 +733,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
@@ -655,9 +752,12 @@ export const SplitView = memo(function SplitView({
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+170
-4
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
@@ -6,31 +7,57 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { ChevronDown, ChevronUp, RefreshCw, X } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
X,
|
||||
LayoutPanelLeft,
|
||||
Plus,
|
||||
Minus,
|
||||
} from "lucide-react";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
|
||||
import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
splitMode,
|
||||
paneTabIds,
|
||||
focusedPaneIndex,
|
||||
onSetActiveTab,
|
||||
onCloseTab,
|
||||
onRefreshTab,
|
||||
onReorderTabs,
|
||||
onSplitTab,
|
||||
onAddToSplit,
|
||||
onRemoveFromSplit,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
splitMode: SplitMode;
|
||||
paneTabIds: (string | null)[];
|
||||
focusedPaneIndex: number | null;
|
||||
onSetActiveTab: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
onRefreshTab: (id: string) => void;
|
||||
onReorderTabs: (tabs: Tab[]) => void;
|
||||
onSplitTab: (tabId: string, mode: SplitMode) => void;
|
||||
onAddToSplit: (tabId: string) => void;
|
||||
onRemoveFromSplit: (tabId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [contextTabId, setContextTabId] = useState<string | null>(null);
|
||||
const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||
const tabEls = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -49,6 +76,9 @@ export function TabBar({
|
||||
const dragTargetRef = useRef<number | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
|
||||
const isSplit = splitMode !== "none";
|
||||
const paneCount = PANE_COUNTS[splitMode];
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabBarRef.current;
|
||||
if (!el) return;
|
||||
@@ -123,6 +153,18 @@ export function TabBar({
|
||||
};
|
||||
}, [dragTabId, tabs, onReorderTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextTabId) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest("[data-context-menu]")) {
|
||||
setContextTabId(null);
|
||||
setContextPos(null);
|
||||
}
|
||||
}
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => window.removeEventListener("mousedown", onDown);
|
||||
}, [contextTabId]);
|
||||
|
||||
const dragIdx = tabs.findIndex((t) => t.id === dragTabId);
|
||||
const target = dragTargetIndex ?? dragIdx;
|
||||
|
||||
@@ -138,6 +180,9 @@ export function TabBar({
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeTabId;
|
||||
const isDragging = dragTabId === tab.id;
|
||||
const paneIdx = paneTabIds.indexOf(tab.id);
|
||||
const isInPane = paneIdx !== -1;
|
||||
const isFocusedPane = isInPane && paneIdx === focusedPaneIndex;
|
||||
let translateX = 0;
|
||||
if (
|
||||
dragTabId &&
|
||||
@@ -153,6 +198,10 @@ export function TabBar({
|
||||
else if (dragIdx > target && index < dragIdx && index >= target)
|
||||
translateX = draggedWidth;
|
||||
}
|
||||
|
||||
const showFocusIndicator = isFocusedPane && isSplit;
|
||||
const showInPaneIndicator = isInPane && isSplit && !isFocusedPane;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
@@ -160,6 +209,12 @@ export function TabBar({
|
||||
if (el) tabEls.current.set(tab.id, el);
|
||||
else tabEls.current.delete(tab.id);
|
||||
}}
|
||||
draggable={isSplit && tab.type !== "dashboard"}
|
||||
onDragStart={(e) => {
|
||||
if (!isSplit || tab.type === "dashboard") return;
|
||||
e.dataTransfer.setData("text/plain", tab.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onClick={() =>
|
||||
!dragTabId && !didDrag.current && onSetActiveTab(tab.id)
|
||||
}
|
||||
@@ -169,6 +224,12 @@ export function TabBar({
|
||||
onCloseTab(tab.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (tab.type === "dashboard") return;
|
||||
e.preventDefault();
|
||||
setContextTabId(tab.id);
|
||||
setContextPos({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || tab.type === "dashboard") return;
|
||||
e.preventDefault();
|
||||
@@ -210,7 +271,7 @@ export function TabBar({
|
||||
: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
className={`group/tab flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm
|
||||
className={`group/tab relative flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm
|
||||
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
|
||||
${
|
||||
tab.type === "dashboard"
|
||||
@@ -218,6 +279,14 @@ export function TabBar({
|
||||
: `px-2.5 md:px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
}`}
|
||||
>
|
||||
{/* Focused-pane indicator: brand accent bottom border overlay */}
|
||||
{showFocusIndicator && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-brand/70 z-10" />
|
||||
)}
|
||||
{/* In-pane (not focused) indicator: subtle dot */}
|
||||
{showInPaneIndicator && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-muted-foreground/40 z-10" />
|
||||
)}
|
||||
{tabIcon(tab.type)}
|
||||
{tab.type !== "dashboard" && tab.label}
|
||||
{tab.type !== "dashboard" && (
|
||||
@@ -312,7 +381,7 @@ export function TabBar({
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{tabIcon(tab.type)}
|
||||
<span className="truncate">
|
||||
{tab.type === "dashboard" ? "Dashboard" : tab.label}
|
||||
{tab.type === "dashboard" ? t("nav.dashboard") : tab.label}
|
||||
</span>
|
||||
</div>
|
||||
{tab.type !== "dashboard" && (
|
||||
@@ -351,6 +420,103 @@ export function TabBar({
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{contextTabId &&
|
||||
contextPos &&
|
||||
(() => {
|
||||
const ctxTab = tabs.find((t) => t.id === contextTabId);
|
||||
if (!ctxTab) return null;
|
||||
const isInPane = paneTabIds.indexOf(contextTabId) !== -1;
|
||||
const hasEmptySlot =
|
||||
isSplit && paneTabIds.slice(0, paneCount).some((p) => p === null);
|
||||
return (
|
||||
<div
|
||||
data-context-menu
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextPos.x,
|
||||
top: contextPos.y,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
className="bg-popover border border-border shadow-lg py-1 min-w-[180px]"
|
||||
>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground truncate max-w-[200px]">
|
||||
{ctxTab.label}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{CONNECTION_TAB_TYPES.includes(ctxTab.type) && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onRefreshTab(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Refresh connection
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
{/* Split submenu */}
|
||||
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Split
|
||||
</div>
|
||||
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onSplitTab(contextTabId, mode.id);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<LayoutPanelLeft className="size-3 text-muted-foreground" />
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
{isSplit && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{isInPane ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground text-muted-foreground"
|
||||
onClick={() => {
|
||||
onRemoveFromSplit(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
Remove from split
|
||||
</button>
|
||||
) : hasEmptySlot ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onAddToSplit(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add to split
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground text-destructive"
|
||||
onClick={() => {
|
||||
onCloseTab(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,6 @@ interface TabContextType {
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
|
||||
export function useTabs() {
|
||||
const context = useContext(TabContext);
|
||||
@@ -79,111 +76,19 @@ interface TabProviderProps {
|
||||
export function clearTermixSessionStorage() {
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith("termix_session_")) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
export function TabProvider({ children }: TabProviderProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
const shouldRestore = isMobile || isElectron || persistenceEnabled;
|
||||
|
||||
if (!shouldRestore) {
|
||||
return [{ id: 1, type: "home", title: "Home" }];
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem("termix_tabs");
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Tab[];
|
||||
const restored: Tab[] = [{ id: 1, type: "home", title: "Home" }];
|
||||
let maxId = 1;
|
||||
for (const tab of parsed) {
|
||||
if (tab.type === "home") continue;
|
||||
const restoredTab: Tab = {
|
||||
...tab,
|
||||
instanceId: tab.instanceId,
|
||||
terminalRef:
|
||||
tab.type === "terminal"
|
||||
? React.createRef<TerminalRefHandle>()
|
||||
: undefined,
|
||||
hostConfig: tab.hostConfig
|
||||
? {
|
||||
...tab.hostConfig,
|
||||
instanceId: tab.instanceId,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
restored.push(restoredTab);
|
||||
if (tab.id > maxId) maxId = tab.id;
|
||||
}
|
||||
if (restored.length > 1) return restored;
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt data */
|
||||
}
|
||||
return [{ id: 1, type: "home", title: "Home" }];
|
||||
});
|
||||
const [currentTab, setCurrentTab] = useState<number>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("termix_currentTab");
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
const [tabs, setTabs] = useState<Tab[]>([{ id: 1, type: "home", title: "Home" }]);
|
||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [initialMaxId] = useState(() => {
|
||||
let maxId = 1;
|
||||
tabs.forEach((tab) => {
|
||||
if (tab.id > maxId) maxId = tab.id;
|
||||
});
|
||||
return maxId + 1;
|
||||
});
|
||||
const [initialMaxId] = useState(2);
|
||||
const nextTabId = useRef(initialMaxId);
|
||||
|
||||
useEffect(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
|
||||
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
||||
|
||||
if (shouldSave) {
|
||||
const serializable = tabs
|
||||
.filter((t) => t.type !== "home")
|
||||
.map((tab) => {
|
||||
const rest = { ...tab };
|
||||
delete rest.terminalRef;
|
||||
return rest;
|
||||
});
|
||||
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
||||
localStorage.setItem("termix_currentTab", String(currentTab));
|
||||
} else {
|
||||
localStorage.removeItem("termix_tabs");
|
||||
localStorage.removeItem("termix_currentTab");
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
// Safety net: if currentTab points to a tab that no longer exists, fall back to home
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((t) => t.id === currentTab)) {
|
||||
|
||||
@@ -132,7 +132,8 @@ function TerminalTabContent({
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.id,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
} as any
|
||||
}
|
||||
isVisible={isVisible}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LayoutPanelLeft,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
@@ -25,6 +26,7 @@ export type RailView =
|
||||
| "credentials"
|
||||
| "quick-connect"
|
||||
| ToolsTab
|
||||
| "connections"
|
||||
| "user-profile"
|
||||
| "admin-settings";
|
||||
|
||||
@@ -42,6 +44,7 @@ type RailItem =
|
||||
function buildRailButtons(
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
connectionCount: number,
|
||||
): RailItem[] {
|
||||
return [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||
@@ -51,6 +54,12 @@ function buildRailButtons(
|
||||
title: t("nav.credentials"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "connections",
|
||||
icon: <Plug size={16} />,
|
||||
title: t("nav.connections"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "quick-connect",
|
||||
icon: <Zap size={16} />,
|
||||
@@ -88,6 +97,7 @@ export function AppRail({
|
||||
railView,
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
connectionCount,
|
||||
username,
|
||||
isAdmin,
|
||||
profileDropdownOpen,
|
||||
@@ -99,6 +109,7 @@ export function AppRail({
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
connectionCount: number;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
profileDropdownOpen: boolean;
|
||||
@@ -109,8 +120,19 @@ export function AppRail({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const railExpanded = hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode, t);
|
||||
const [pinned, setPinned] = useState(
|
||||
() => localStorage.getItem("pinAppRail") === "true",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setPinned(localStorage.getItem("pinAppRail") === "true");
|
||||
window.addEventListener("pinAppRailChanged", handler);
|
||||
return () => window.removeEventListener("pinAppRailChanged", handler);
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode, t, connectionCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Plug, Search, X } from "lucide-react";
|
||||
import { getActiveSessions, deleteOpenTab, type ActiveSessionInfo, type OpenTabRecord } from "@/main-axios";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { Input } from "@/components/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/tooltip";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
"files",
|
||||
"docker",
|
||||
"stats",
|
||||
"tunnel",
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
terminal: "SSH",
|
||||
rdp: "RDP",
|
||||
vnc: "VNC",
|
||||
telnet: "Telnet",
|
||||
files: "Files",
|
||||
docker: "Docker",
|
||||
stats: "Stats",
|
||||
tunnel: "Tunnel",
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ${s % 60}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function formatExpiry(updatedAt: string): string {
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const elapsed = Date.now() - new Date(updatedAt).getTime();
|
||||
const remaining = TTL_MS - elapsed;
|
||||
if (remaining <= 0) return "expiring";
|
||||
return formatDuration(remaining);
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
isActive,
|
||||
isLive,
|
||||
tabType,
|
||||
name,
|
||||
subLabel,
|
||||
icon,
|
||||
onSwitch,
|
||||
onClose,
|
||||
switchTitle,
|
||||
faded,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
isLive: boolean;
|
||||
tabType: string;
|
||||
name: string;
|
||||
subLabel: string;
|
||||
icon: React.ReactNode;
|
||||
onSwitch?: () => void;
|
||||
onClose: () => void;
|
||||
switchTitle?: string;
|
||||
faded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role={onSwitch ? "button" : undefined}
|
||||
tabIndex={onSwitch ? 0 : undefined}
|
||||
onClick={onSwitch}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSwitch?.()}
|
||||
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
|
||||
faded ? "opacity-60" : ""
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
|
||||
: onSwitch
|
||||
? "hover:bg-muted/40 cursor-pointer"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`shrink-0 flex items-center justify-center size-7 rounded ${
|
||||
isActive
|
||||
? "bg-accent-brand/15 text-accent-brand"
|
||||
: "bg-muted/60 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`shrink-0 size-1.5 rounded-full ${
|
||||
isLive ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-semibold truncate flex-1 ${
|
||||
isActive ? "text-accent-brand" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
|
||||
>
|
||||
{TYPE_LABELS[tabType] ?? tabType}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
|
||||
{subLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{switchTitle && onSwitch && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSwitch(); }}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{switchTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionsPanel({
|
||||
tabs,
|
||||
activeTabId,
|
||||
allHosts,
|
||||
backgroundTabRecords,
|
||||
onSwitchToTab,
|
||||
onCloseTab,
|
||||
onReopenTab,
|
||||
onForgetBackground,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
allHosts: { id: string; name: string }[];
|
||||
backgroundTabRecords: OpenTabRecord[];
|
||||
onSwitchToTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => void;
|
||||
onReopenTab: (record: OpenTabRecord, restoredSessionId: string | null) => void;
|
||||
onForgetBackground: (recordId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type));
|
||||
|
||||
// Filter background records to only those not already open in the tab bar
|
||||
const openInstanceIds = new Set(tabs.map((t) => t.instanceId).filter(Boolean));
|
||||
const backgroundTabs = backgroundTabRecords.filter((r) => !openInstanceIds.has(r.id));
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filteredOpenTabs = q
|
||||
? openTabs.filter((tab) => (tab.host?.name ?? tab.label).toLowerCase().includes(q))
|
||||
: openTabs;
|
||||
const filteredBackgroundTabs = q
|
||||
? backgroundTabs.filter((r) => {
|
||||
const host = allHosts.find((h) => h.id === String(r.hostId));
|
||||
return (host?.name ?? r.label).toLowerCase().includes(q);
|
||||
})
|
||||
: backgroundTabs;
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const sessions = await getActiveSessions();
|
||||
setActiveSessions((prev) => {
|
||||
const next = Array.isArray(sessions) ? sessions : [];
|
||||
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
pollTimerRef.current = setInterval(refresh, 5000);
|
||||
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); };
|
||||
}, [refresh]);
|
||||
|
||||
const sessionByInstanceId = new Map(activeSessions.map((s) => [s.tabInstanceId, s]));
|
||||
|
||||
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
|
||||
const hasResults = filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Plug className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("connections.noConnections")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("connections.noConnectionsDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative px-3 py-2 border-b border-border/60">
|
||||
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
placeholder={t("connections.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasResults && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<span className="text-xs text-muted-foreground/50">{t("connections.noSearchResults")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredOpenTabs.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader label={t("connections.sectionOpen")} count={filteredOpenTabs.length} />
|
||||
{filteredOpenTabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const liveSession = tab.instanceId ? sessionByInstanceId.get(tab.instanceId) : undefined;
|
||||
const isLive = tab.type === "terminal"
|
||||
? (liveSession?.isConnected ?? false)
|
||||
: true;
|
||||
const duration = liveSession?.createdAt
|
||||
? formatDuration(now - liveSession.createdAt)
|
||||
: formatDuration(now - tab.openedAt);
|
||||
|
||||
return (
|
||||
<ConnectionRow
|
||||
key={tab.id}
|
||||
isActive={isActive}
|
||||
isLive={isLive}
|
||||
tabType={tab.type}
|
||||
name={tab.host?.name ?? tab.label}
|
||||
subLabel={
|
||||
isLive && tab.type === "terminal"
|
||||
? t("connections.connectedFor", { duration })
|
||||
: isLive
|
||||
? t("connections.connected")
|
||||
: t("connections.disconnected")
|
||||
}
|
||||
icon={tabIcon(tab.type)}
|
||||
onSwitch={() => onSwitchToTab(tab.id)}
|
||||
onClose={() => onCloseTab(tab.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredBackgroundTabs.length > 0 && (
|
||||
<div className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}>
|
||||
<SectionHeader label={t("connections.sectionBackground")} count={filteredBackgroundTabs.length} />
|
||||
<div className="px-3 py-1.5 border-b border-border/40">
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{t("connections.backgroundDesc")}
|
||||
</span>
|
||||
</div>
|
||||
{filteredBackgroundTabs.map((record) => {
|
||||
const host = record.hostId
|
||||
? allHosts.find((h) => h.id === String(record.hostId))
|
||||
: undefined;
|
||||
const expiresIn = formatExpiry(record.updatedAt);
|
||||
|
||||
return (
|
||||
<ConnectionRow
|
||||
key={record.id}
|
||||
isLive={false}
|
||||
faded
|
||||
tabType={record.tabType}
|
||||
name={host?.name ?? record.label}
|
||||
subLabel={t("connections.expiresIn", { duration: expiresIn })}
|
||||
icon={tabIcon(record.tabType as TabType)}
|
||||
onSwitch={() => {
|
||||
const liveSession = sessionByInstanceId.get(record.id);
|
||||
onReopenTab(record, liveSession?.sessionId ?? null);
|
||||
}}
|
||||
onClose={async () => {
|
||||
await deleteOpenTab(record.id).catch(() => {});
|
||||
onForgetBackground(record.id);
|
||||
}}
|
||||
switchTitle={t("connections.reconnect")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -356,7 +356,7 @@ function HostEditor({
|
||||
name: host?.name ?? "",
|
||||
ip: host?.ip ?? "",
|
||||
username: host?.username ?? "",
|
||||
sshPort: host?.sshPort ?? 22,
|
||||
sshPort: host?.sshPort ?? host?.port ?? 22,
|
||||
rdpPort: host?.rdpPort ?? 3389,
|
||||
vncPort: host?.vncPort ?? 5900,
|
||||
telnetPort: host?.telnetPort ?? 23,
|
||||
@@ -430,8 +430,8 @@ function HostEditor({
|
||||
autoTmux: host?.terminalConfig?.autoTmux ?? false,
|
||||
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
|
||||
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
|
||||
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 30,
|
||||
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 3,
|
||||
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
|
||||
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
|
||||
environmentVariables:
|
||||
host?.terminalConfig?.environmentVariables ??
|
||||
([] as { key: string; value: string }[]),
|
||||
@@ -4703,7 +4703,7 @@ export function HostManager({
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const reloadHosts = () => {
|
||||
getSSHHosts()
|
||||
.then((raw) => {
|
||||
const converted = raw.map(sshHostToHost);
|
||||
@@ -4711,6 +4711,10 @@ export function HostManager({
|
||||
applyPendingEdit(converted);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadHosts();
|
||||
getCredentials()
|
||||
.then((res: any) => {
|
||||
const arr = Array.isArray(res) ? res : [];
|
||||
@@ -4729,6 +4733,10 @@ export function HostManager({
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setCredentialsLoading(false));
|
||||
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
return () =>
|
||||
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -4749,6 +4757,7 @@ export function HostManager({
|
||||
} else if (action === "add-credential") {
|
||||
setEditingCredential("new");
|
||||
setEditingHost(null);
|
||||
setActiveCredentialTab("general");
|
||||
}
|
||||
}
|
||||
}, [pendingEditId, pendingAction]);
|
||||
@@ -4770,6 +4779,7 @@ export function HostManager({
|
||||
const handleAddCredential = () => {
|
||||
setEditingCredential("new");
|
||||
setEditingHost(null);
|
||||
setActiveCredentialTab("general");
|
||||
};
|
||||
const handleEditHost = (e: Event) => {
|
||||
const id = (e as CustomEvent<string>).detail;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Download,
|
||||
Filter,
|
||||
ListChecks,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -10,20 +13,137 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SidebarTree } from "@/sidebar/SidebarTree";
|
||||
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { getSSHHosts, bulkImportSSHHosts } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
|
||||
type SortKey =
|
||||
| "default"
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
| "ip-asc"
|
||||
| "ip-desc"
|
||||
| "status-online"
|
||||
| "status-offline"
|
||||
| "pinned";
|
||||
|
||||
type FilterState = {
|
||||
status: ("online" | "offline" | "pinned")[];
|
||||
authType: ("password" | "key" | "credential" | "none" | "opkssh")[];
|
||||
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
|
||||
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
status: [],
|
||||
authType: [],
|
||||
protocol: [],
|
||||
features: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
function sortHostTree(folder: HostFolder, key: SortKey): HostFolder {
|
||||
if (key === "default") return folder;
|
||||
|
||||
const comparator = (a: Host | HostFolder, b: Host | HostFolder): number => {
|
||||
const aIsFolder = isFolder(a);
|
||||
const bIsFolder = isFolder(b);
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
if (aIsFolder && bIsFolder)
|
||||
return (a as HostFolder).name.localeCompare((b as HostFolder).name);
|
||||
const ha = a as Host,
|
||||
hb = b as Host;
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
return ha.name.localeCompare(hb.name);
|
||||
case "name-desc":
|
||||
return hb.name.localeCompare(ha.name);
|
||||
case "ip-asc":
|
||||
return ha.ip.localeCompare(hb.ip);
|
||||
case "ip-desc":
|
||||
return hb.ip.localeCompare(ha.ip);
|
||||
case "status-online":
|
||||
return (hb.online ? 1 : 0) - (ha.online ? 1 : 0);
|
||||
case "status-offline":
|
||||
return (ha.online ? 1 : 0) - (hb.online ? 1 : 0);
|
||||
case "pinned":
|
||||
return (hb.pin ? 1 : 0) - (ha.pin ? 1 : 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedChildren = [...folder.children]
|
||||
.sort(comparator)
|
||||
.map((child) => (isFolder(child) ? sortHostTree(child, key) : child));
|
||||
return { ...folder, children: sortedChildren };
|
||||
}
|
||||
|
||||
function hostPassesFilters(host: Host, filters: FilterState): boolean {
|
||||
if (filters.status.length > 0) {
|
||||
const ok =
|
||||
(filters.status.includes("online") && host.online) ||
|
||||
(filters.status.includes("offline") && !host.online) ||
|
||||
(filters.status.includes("pinned") && !!host.pin);
|
||||
if (!ok) return false;
|
||||
}
|
||||
if (filters.authType.length > 0) {
|
||||
if (!filters.authType.includes(host.authType as FilterState["authType"][number])) return false;
|
||||
}
|
||||
if (filters.protocol.length > 0) {
|
||||
const ok =
|
||||
(filters.protocol.includes("ssh") && host.enableSsh) ||
|
||||
(filters.protocol.includes("rdp") && host.enableRdp) ||
|
||||
(filters.protocol.includes("vnc") && host.enableVnc) ||
|
||||
(filters.protocol.includes("telnet") && host.enableTelnet);
|
||||
if (!ok) return false;
|
||||
}
|
||||
if (filters.features.length > 0) {
|
||||
const ok =
|
||||
(filters.features.includes("terminal") && host.enableTerminal) ||
|
||||
(filters.features.includes("fileManager") && host.enableFileManager) ||
|
||||
(filters.features.includes("tunnel") && host.enableTunnel) ||
|
||||
(filters.features.includes("docker") && host.enableDocker);
|
||||
if (!ok) return false;
|
||||
}
|
||||
if (filters.tags.length > 0) {
|
||||
const ok = filters.tags.some((tag) => host.tags?.includes(tag));
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFilters(folder: HostFolder, filters: FilterState): HostFolder {
|
||||
const active = Object.values(filters).some((arr) => arr.length > 0);
|
||||
if (!active) return folder;
|
||||
|
||||
const filteredChildren = folder.children
|
||||
.map((child) => {
|
||||
if (isFolder(child)) return applyFilters(child, filters);
|
||||
return hostPassesFilters(child, filters) ? child : null;
|
||||
})
|
||||
.filter((child): child is Host | HostFolder => {
|
||||
if (child === null) return false;
|
||||
if (isFolder(child)) return child.children.length > 0;
|
||||
return true;
|
||||
});
|
||||
return { ...folder, children: filteredChildren };
|
||||
}
|
||||
|
||||
export function HostsPanel({
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
@@ -46,8 +166,46 @@ export function HostsPanel({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [rawHosts, setRawHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [shareModalHost, setShareModalHost] = useState<Host | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>(
|
||||
() => (localStorage.getItem("hostSortKey") as SortKey) ?? "default",
|
||||
);
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("hostFilterState");
|
||||
return saved ? (JSON.parse(saved) as FilterState) : DEFAULT_FILTERS;
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
});
|
||||
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const importOverwriteRef = useRef(false);
|
||||
const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))];
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortKey(key);
|
||||
localStorage.setItem("hostSortKey", key);
|
||||
}
|
||||
|
||||
function handleFilterToggle<K extends keyof FilterState>(
|
||||
group: K,
|
||||
value: FilterState[K][number],
|
||||
) {
|
||||
setFilterState((prev) => {
|
||||
const arr = prev[group] as string[];
|
||||
const next = arr.includes(value as string)
|
||||
? arr.filter((v) => v !== value)
|
||||
: [...arr, value as string];
|
||||
const updated = { ...prev, [group]: next };
|
||||
localStorage.setItem("hostFilterState", JSON.stringify(updated));
|
||||
return updated as FilterState;
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterClear() {
|
||||
setFilterState(DEFAULT_FILTERS);
|
||||
localStorage.setItem("hostFilterState", JSON.stringify(DEFAULT_FILTERS));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
@@ -226,8 +384,8 @@ export function HostsPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 shrink-0 flex-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -294,6 +452,195 @@ export function HostsPanel({
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${sortKey !== "default" ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("hosts.sortHosts")}
|
||||
>
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[160px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortChange("default")}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === "default" ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t("hosts.sortDefault")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(["name-asc", "name-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
`hosts.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["ip-asc", "ip-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(`hosts.sort${key === "ip-asc" ? "IpAsc" : "IpDesc"}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["status-online", "status-offline", "pinned"] as const).map(
|
||||
(key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
key === "status-online"
|
||||
? "hosts.sortOnlineFirst"
|
||||
: key === "status-offline"
|
||||
? "hosts.sortOfflineFirst"
|
||||
: "hosts.sortPinnedFirst",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("hosts.filterHosts")}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[180px]"
|
||||
>
|
||||
{filterActive && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={handleFilterClear}
|
||||
className="flex items-center gap-1.5 text-accent-brand"
|
||||
>
|
||||
<X className="size-3 shrink-0" />
|
||||
{t("hosts.filterClearAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("hosts.filterStatusGroup")}</DropdownMenuLabel>
|
||||
{(["online", "offline", "pinned"] as const).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.status.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("status", val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterAuthGroup")}</DropdownMenuLabel>
|
||||
{(["password", "key", "credential", "none", "opkssh"] as const).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.authType.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("authType", val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterProtocolGroup")}</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["ssh", "Ssh"],
|
||||
["rdp", "Rdp"],
|
||||
["vnc", "Vnc"],
|
||||
["telnet", "Telnet"],
|
||||
] as const
|
||||
).map(([val, key]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.protocol.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("protocol", val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterProtocol${key}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterFeaturesGroup")}</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["terminal", "Terminal"],
|
||||
["fileManager", "FileManager"],
|
||||
["tunnel", "Tunnel"],
|
||||
["docker", "Docker"],
|
||||
] as const
|
||||
).map(([val, key]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.features.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("features", val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterFeature${key}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{allTags.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterTagsGroup")}</DropdownMenuLabel>
|
||||
{allTags.map((tag) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tag}
|
||||
checked={filterState.tags.includes(tag)}
|
||||
onCheckedChange={() => handleFilterToggle("tags", tag)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{tag}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -313,7 +660,7 @@ export function HostsPanel({
|
||||
className={`flex flex-col flex-1 min-h-0 ${managerEditing ? "hidden" : ""}`}
|
||||
>
|
||||
<SidebarTree
|
||||
children={hostTree?.children ?? []}
|
||||
children={hostTree ? applyFilters(sortHostTree(hostTree, sortKey), filterState).children : []}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={(host) => setShareModalHost(host)}
|
||||
|
||||
+145
-21
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Box,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -36,7 +37,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { bulkUpdateSSHHosts, createSSHHost, deleteSSHHost } from "@/main-axios";
|
||||
import {
|
||||
bulkUpdateSSHHosts,
|
||||
createSSHHost,
|
||||
deleteSSHHost,
|
||||
wakeOnLan,
|
||||
} from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
|
||||
export function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
@@ -186,6 +192,8 @@ export function HostItem({
|
||||
onToggleSelect,
|
||||
isMenuOpen = false,
|
||||
onMenuOpenChange,
|
||||
isTrayOpen = false,
|
||||
onTrayOpenChange,
|
||||
}: {
|
||||
host: Host;
|
||||
onOpenTab: (type: TabType) => void;
|
||||
@@ -200,10 +208,43 @@ export function HostItem({
|
||||
onToggleSelect?: () => void;
|
||||
isMenuOpen?: boolean;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
isTrayOpen?: boolean;
|
||||
onTrayOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const metricsEnabled =
|
||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
);
|
||||
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("hostTrayOnClickChanged", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener("hostTrayOnClickChanged", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
setShowHostTags(v !== null ? v === "true" : true);
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("showHostTagsChanged", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener("showHostTagsChanged", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (query && !hostMatchesQuery(host, query)) return null;
|
||||
|
||||
@@ -224,7 +265,11 @@ export function HostItem({
|
||||
// On touch devices open the action tray instead of immediately launching a tab
|
||||
if (window.matchMedia("(hover: none)").matches) {
|
||||
e.stopPropagation();
|
||||
onMenuOpenChange?.(!isMenuOpen);
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
return;
|
||||
}
|
||||
if (trayOnClick) {
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
return;
|
||||
}
|
||||
if (host.enableSsh) onOpenTab("terminal");
|
||||
@@ -260,15 +305,15 @@ export function HostItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address — only visible on hover or while menu is open */}
|
||||
{/* Address — only visible on hover (or click when trayOnClick) or while menu is open */}
|
||||
<span
|
||||
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 group-hover:opacity-100 group-hover:h-auto ${isMenuOpen ? "opacity-100 h-auto" : "opacity-0 h-0 overflow-hidden"}`}
|
||||
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 ${!trayOnClick ? "group-hover:opacity-100 group-hover:h-auto" : ""} ${isMenuOpen || (trayOnClick && isTrayOpen) ? "opacity-100 h-auto" : "opacity-0 h-0 overflow-hidden"}`}
|
||||
>
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
{/* Tag pills */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
{showHostTags && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
|
||||
{host.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
@@ -286,9 +331,9 @@ export function HostItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action tray — slides open on CSS hover or while menu is open */}
|
||||
{/* Action tray — slides open on CSS hover, on click (when trayOnClick), or while menu is open */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[300px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!trayOnClick ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (trayOnClick && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
>
|
||||
{host.online &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
@@ -382,6 +427,25 @@ export function HostItem({
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator + management buttons row — always fixed position */}
|
||||
@@ -601,6 +665,8 @@ export function FolderItem({
|
||||
onToggleSelect,
|
||||
openMenuHostId,
|
||||
onMenuOpenChange,
|
||||
openTrayHostId,
|
||||
onTrayOpenChange,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
depth?: number;
|
||||
@@ -618,6 +684,8 @@ export function FolderItem({
|
||||
onToggleSelect: (id: string) => void;
|
||||
openMenuHostId: string | null;
|
||||
onMenuOpenChange: (hostId: string | null) => void;
|
||||
openTrayHostId: string | null;
|
||||
onTrayOpenChange: (hostId: string | null) => void;
|
||||
}) {
|
||||
const { total, online } = folderHostCount(folder);
|
||||
|
||||
@@ -670,6 +738,8 @@ export function FolderItem({
|
||||
onToggleSelect={onToggleSelect}
|
||||
openMenuHostId={openMenuHostId}
|
||||
onMenuOpenChange={onMenuOpenChange}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={onTrayOpenChange}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
@@ -689,6 +759,10 @@ export function FolderItem({
|
||||
onMenuOpenChange={(open) =>
|
||||
onMenuOpenChange(open ? child.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayHostId === child.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
onTrayOpenChange(open ? child.id : null)
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -723,6 +797,7 @@ export function SidebarTree({
|
||||
new Set(),
|
||||
);
|
||||
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
|
||||
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
@@ -761,21 +836,64 @@ export function SidebarTree({
|
||||
|
||||
async function handleDuplicateHost(host: Host) {
|
||||
try {
|
||||
const {
|
||||
id: _id,
|
||||
online: _online,
|
||||
cpu: _cpu,
|
||||
ram: _ram,
|
||||
lastAccess: _la,
|
||||
hasKey: _hk,
|
||||
hasKeyPassword: _hkp,
|
||||
...rest
|
||||
} = host as any;
|
||||
await createSSHHost({
|
||||
...rest,
|
||||
name: `${host.name} (copy)`,
|
||||
key: undefined,
|
||||
});
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags ?? [],
|
||||
pin: host.pin ?? false,
|
||||
notes: host.notes,
|
||||
macAddress: host.macAddress,
|
||||
authType: host.authType,
|
||||
password: host.password ?? null,
|
||||
keyPassword: host.keyPassword ?? null,
|
||||
keyType: host.keyType ?? null,
|
||||
credentialId: host.credentialId ? Number(host.credentialId) : null,
|
||||
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
|
||||
enableSsh: host.enableSsh,
|
||||
enableRdp: host.enableRdp,
|
||||
enableVnc: host.enableVnc,
|
||||
enableTelnet: host.enableTelnet,
|
||||
enableTerminal: host.enableTerminal,
|
||||
enableTunnel: host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager,
|
||||
enableDocker: host.enableDocker,
|
||||
sshPort: host.sshPort,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
telnetPort: host.telnetPort,
|
||||
rdpUser: host.rdpUser ?? null,
|
||||
rdpPassword: host.rdpPassword ?? null,
|
||||
rdpDomain: host.domain ?? null,
|
||||
rdpSecurity: host.security ?? null,
|
||||
rdpIgnoreCert: host.ignoreCert ?? false,
|
||||
vncPassword: host.vncPassword ?? null,
|
||||
vncUser: host.vncUser ?? null,
|
||||
telnetUser: host.telnetUser ?? null,
|
||||
telnetPassword: host.telnetPassword ?? null,
|
||||
defaultPath: host.defaultPath ?? "/",
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host ?? null,
|
||||
socks5Port: host.socks5Port ?? null,
|
||||
socks5Username: host.socks5Username ?? null,
|
||||
socks5Password: host.socks5Password ?? null,
|
||||
socks5ProxyChain: host.socks5ProxyChain ?? null,
|
||||
jumpHosts: (host.jumpHosts ?? []).map((j) => ({
|
||||
hostId: Number(j.hostId),
|
||||
})),
|
||||
portKnockSequence: host.portKnockSequence ?? [],
|
||||
tunnelConnections: host.serverTunnels ?? [],
|
||||
quickActions: (host.quickActions ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
snippetId: Number(a.snippetId),
|
||||
})),
|
||||
statsConfig: host.statsConfig,
|
||||
guacamoleConfig: host.guacamoleConfig ?? null,
|
||||
terminalConfig: host.terminalConfig ?? null,
|
||||
} as any);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.duplicatedHost", { name: host.name }));
|
||||
} catch {
|
||||
@@ -846,6 +964,8 @@ export function SidebarTree({
|
||||
onToggleSelect={toggleSelect}
|
||||
openMenuHostId={openMenuHostId}
|
||||
onMenuOpenChange={setOpenMenuHostId}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={setOpenTrayHostId}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
@@ -865,6 +985,10 @@ export function SidebarTree({
|
||||
onMenuOpenChange={(open) =>
|
||||
setOpenMenuHostId(open ? child.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayHostId === child.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
setOpenTrayHostId(open ? child.id : null)
|
||||
}
|
||||
/>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getSnippets,
|
||||
createSnippet as apiCreateSnippet,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
revokeSnippetAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
reorderSnippets,
|
||||
} from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
Database,
|
||||
Folder,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -122,7 +125,7 @@ function SnippetFormDialog({
|
||||
onOpenChange: (v: boolean) => void;
|
||||
folders: SnippetFolder[];
|
||||
snippet: Snippet | null;
|
||||
onSave: (data: Omit<Snippet, "id">, id?: number) => void;
|
||||
onSave: (data: Omit<Snippet, "id" | "order">, id?: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
@@ -685,6 +688,12 @@ function SnippetCard({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
t,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
@@ -693,9 +702,15 @@ function SnippetCard({
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
dropIndicator: "above" | "below" | null;
|
||||
isDragging: boolean;
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
function handleRun() {
|
||||
function executeRun() {
|
||||
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((tab) => {
|
||||
@@ -723,6 +738,10 @@ function SnippetCard({
|
||||
}
|
||||
}
|
||||
|
||||
function handleRun() {
|
||||
onConfirmRun(snippet, executeRun);
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(snippet.content);
|
||||
toast.success(
|
||||
@@ -731,69 +750,80 @@ function SnippetCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-background p-2.5 flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="relative" onDragOver={onDragOver}>
|
||||
{dropIndicator === "above" && (
|
||||
<div className="absolute -top-1 left-0 right-0 h-0.5 bg-accent-brand z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`border bg-background p-2.5 flex flex-col gap-2 group/card transition-opacity ${isDragging ? "opacity-40" : "opacity-100"} border-border`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.content}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
onClick={handleRun}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onEdit(snippet)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => onDelete(snippet.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onShare(snippet)}
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.content}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
onClick={handleRun}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onEdit(snippet)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => onDelete(snippet.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onShare(snippet)}
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{dropIndicator === "below" && (
|
||||
<div className="absolute -bottom-1 left-0 right-0 h-0.5 bg-accent-brand z-10 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -806,9 +836,11 @@ export function SnippetsPanel({
|
||||
activeTabId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [snippetSearch, setSnippetSearch] = useState("");
|
||||
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const snippetsRef = useRef<Snippet[]>([]);
|
||||
const [snippetFormOpen, setSnippetFormOpen] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
@@ -823,9 +855,123 @@ export function SnippetsPanel({
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const [uncategorizedOpen, setUncategorizedOpen] = useState(true);
|
||||
|
||||
function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
|
||||
setSnippets((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
snippetsRef.current = resolved;
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
const getFoldersCollapsed = () =>
|
||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
|
||||
|
||||
const [uncategorizedOpen, setUncategorizedOpen] = useState(
|
||||
() => !getFoldersCollapsed(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
setUncategorizedOpen(!collapsed);
|
||||
setFolders((prev) => prev.map((f) => ({ ...f, open: !collapsed })));
|
||||
};
|
||||
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"defaultSnippetFoldersCollapsedChanged",
|
||||
handler,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{
|
||||
id: number;
|
||||
position: "above" | "below";
|
||||
} | null>(null);
|
||||
|
||||
function handleDragStart(snippet: Snippet) {
|
||||
setDraggedSnippet(snippet);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, snippetId: number) {
|
||||
e.preventDefault();
|
||||
if (!draggedSnippet || draggedSnippet.id === snippetId) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const position = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
|
||||
setDropTarget((prev) =>
|
||||
prev?.id === snippetId && prev?.position === position
|
||||
? prev
|
||||
: { id: snippetId, position },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDrop(folder: string | null) {
|
||||
const dragged = draggedSnippet;
|
||||
const target = dropTarget;
|
||||
setDraggedSnippet(null);
|
||||
setDropTarget(null);
|
||||
|
||||
if (!dragged || !target || dragged.id === target.id) return;
|
||||
|
||||
const group = snippetsRef.current.filter((s) => s.folder === folder);
|
||||
const draggedIdx = group.findIndex((s) => s.id === dragged.id);
|
||||
const targetIdx = group.findIndex((s) => s.id === target.id);
|
||||
if (draggedIdx === -1 || targetIdx === -1) return;
|
||||
|
||||
const reordered = [...group];
|
||||
reordered.splice(draggedIdx, 1);
|
||||
const insertAt =
|
||||
target.position === "below"
|
||||
? reordered.findIndex((s) => s.id === target.id) + 1
|
||||
: reordered.findIndex((s) => s.id === target.id);
|
||||
reordered.splice(insertAt, 0, dragged);
|
||||
|
||||
const rest = snippetsRef.current.filter((s) => s.folder !== folder);
|
||||
const next = [...rest, ...reordered];
|
||||
snippetsRef.current = next;
|
||||
setSnippets(next);
|
||||
|
||||
const payload = reordered.map((s, idx) => ({
|
||||
id: s.id,
|
||||
order: idx,
|
||||
folder: folder ?? undefined,
|
||||
}));
|
||||
try {
|
||||
await reorderSnippets(payload);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.reorderFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
setDraggedSnippet(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
const handleConfirmRun = useCallback(
|
||||
(snippet: Snippet, execute: () => void) => {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
if (!shouldConfirm) {
|
||||
execute();
|
||||
return;
|
||||
}
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", { name: snippet.name }),
|
||||
execute,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
},
|
||||
[confirmWithToast, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
@@ -836,8 +982,9 @@ export function SnippetsPanel({
|
||||
description: s.description,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
}));
|
||||
setSnippets(mapped);
|
||||
updateSnippets(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -850,7 +997,7 @@ export function SnippetsPanel({
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: true,
|
||||
open: !collapsed,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
})
|
||||
@@ -865,23 +1012,27 @@ export function SnippetsPanel({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveSnippet(data: Omit<Snippet, "id">, id?: number) {
|
||||
async function handleSaveSnippet(
|
||||
data: Omit<Snippet, "id" | "order">,
|
||||
id?: number,
|
||||
) {
|
||||
try {
|
||||
if (id !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await apiUpdateSnippet(id, data as any);
|
||||
setSnippets((prev) =>
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
|
||||
);
|
||||
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippet(data as any)) as any;
|
||||
setSnippets((prev) => [
|
||||
updateSnippets((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...data,
|
||||
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
order: created.order ?? prev.length,
|
||||
},
|
||||
]);
|
||||
toast.success(t("newUi.sidebar.snippets.createSuccess"));
|
||||
@@ -919,7 +1070,7 @@ export function SnippetsPanel({
|
||||
try {
|
||||
await apiDeleteSnippetFolder(folder.name);
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folder.id));
|
||||
setSnippets((prev) =>
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) =>
|
||||
s.folder === folder.name ? { ...s, folder: null } : s,
|
||||
),
|
||||
@@ -940,7 +1091,7 @@ export function SnippetsPanel({
|
||||
const nameChanged = data.name !== oldName;
|
||||
if (nameChanged) {
|
||||
await apiRenameSnippetFolder(oldName, data.name);
|
||||
setSnippets((prev) =>
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) =>
|
||||
s.folder === oldName ? { ...s, folder: data.name } : s,
|
||||
),
|
||||
@@ -966,7 +1117,7 @@ export function SnippetsPanel({
|
||||
async function handleDeleteSnippet(id: number) {
|
||||
try {
|
||||
await apiDeleteSnippet(id);
|
||||
setSnippets((prev) => prev.filter((s) => s.id !== id));
|
||||
updateSnippets((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.deleteFailed"));
|
||||
}
|
||||
@@ -1109,7 +1260,11 @@ export function SnippetsPanel({
|
||||
</span>
|
||||
</button>
|
||||
{uncategorizedOpen && (
|
||||
<div className="flex flex-col gap-2 ml-1">
|
||||
<div
|
||||
className="flex flex-col gap-2 ml-1"
|
||||
onDrop={() => handleDrop(null)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{uncategorizedSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
@@ -1119,6 +1274,16 @@ export function SnippetsPanel({
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
dropIndicator={
|
||||
dropTarget?.id === snippet.id
|
||||
? dropTarget.position
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -1188,7 +1353,11 @@ export function SnippetsPanel({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{folder.open && (
|
||||
<div className="flex flex-col gap-2 ml-1">
|
||||
<div
|
||||
className="flex flex-col gap-2 ml-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleDrop(folder.name)}
|
||||
>
|
||||
{folderSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
@@ -1198,6 +1367,16 @@ export function SnippetsPanel({
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
dropIndicator={
|
||||
dropTarget?.id === snippet.id
|
||||
? dropTarget.position
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { LayoutPanelLeft, X } from "lucide-react";
|
||||
import { LayoutPanelLeft, X, ChevronDown } from "lucide-react";
|
||||
import { PANE_COUNTS, SPLIT_MODES } from "@/lib/theme";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, SplitMode } from "@/types/ui-types";
|
||||
@@ -72,23 +72,24 @@ export function SplitScreenPanel({
|
||||
setSplitMode,
|
||||
paneTabIds,
|
||||
setPaneTabIds,
|
||||
onAssignPane,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
splitMode: SplitMode;
|
||||
setSplitMode: (m: SplitMode) => void;
|
||||
paneTabIds: (string | null)[];
|
||||
setPaneTabIds: (ids: (string | null)[]) => void;
|
||||
onAssignPane: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const paneCount = PANE_COUNTS[splitMode];
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const [dragOverPane, setDragOverPane] = useState<number | null>(null);
|
||||
const [quickAssignPane, setQuickAssignPane] = useState<number | null>(null);
|
||||
|
||||
function handleDrop(paneIndex: number) {
|
||||
if (draggingTabId === null) return;
|
||||
const next = [...paneTabIds];
|
||||
next[paneIndex] = draggingTabId;
|
||||
setPaneTabIds(next);
|
||||
onAssignPane(paneIndex, draggingTabId);
|
||||
setDraggingTabId(null);
|
||||
setDragOverPane(null);
|
||||
}
|
||||
@@ -172,51 +173,104 @@ export function SplitScreenPanel({
|
||||
? tabs.find((t) => t.id === assignedId)
|
||||
: null;
|
||||
const isOver = dragOverPane === i;
|
||||
const isQuickAssignOpen = quickAssignPane === i;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverPane(i);
|
||||
}}
|
||||
onDragLeave={() => setDragOverPane(null)}
|
||||
onDrop={() => handleDrop(i)}
|
||||
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
|
||||
isOver
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: assignedTab
|
||||
? "border-border bg-muted/20"
|
||||
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
{assignedTab ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-1 w-full justify-center">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(assignedTab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-semibold truncate max-w-[70px]">
|
||||
{assignedTab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: assignedTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => clearPane(i)}
|
||||
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{isOver
|
||||
? t("newUi.sidebar.splitScreen.dropHere")
|
||||
: t("newUi.sidebar.splitScreen.emptyPane")}
|
||||
<div key={i} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverPane(i);
|
||||
}}
|
||||
onDragLeave={() => setDragOverPane(null)}
|
||||
onDrop={() => handleDrop(i)}
|
||||
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
|
||||
isOver
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: assignedTab
|
||||
? "border-accent-brand/30 bg-accent-brand/5"
|
||||
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
{assignedTab ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-1 w-full justify-center">
|
||||
<span className="text-accent-brand shrink-0">
|
||||
{tabIcon(assignedTab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-semibold truncate max-w-[70px] text-foreground">
|
||||
{assignedTab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: assignedTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => clearPane(i)}
|
||||
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{isOver
|
||||
? t("newUi.sidebar.splitScreen.dropHere")
|
||||
: t("newUi.sidebar.splitScreen.emptyPane")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick assign button */}
|
||||
{!assignedTab && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
setQuickAssignPane(isQuickAssignOpen ? null : i)
|
||||
}
|
||||
className="flex items-center justify-center gap-1 w-full py-0.5 text-[10px] text-muted-foreground/60 hover:text-accent-brand hover:bg-accent-brand/5 border border-dashed border-border/40 hover:border-accent-brand/30 transition-colors"
|
||||
>
|
||||
{t("newUi.sidebar.splitScreen.quickAssign")}
|
||||
<ChevronDown
|
||||
className={`size-2.5 transition-transform ${isQuickAssignOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isQuickAssignOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-20 bg-popover border border-border shadow-md mt-0.5 max-h-40 overflow-y-auto">
|
||||
{tabs
|
||||
.filter(
|
||||
(tab) =>
|
||||
tab.type !== "dashboard" &&
|
||||
!paneTabIds.includes(tab.id),
|
||||
)
|
||||
.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
onAssignPane(i, tab.id);
|
||||
setQuickAssignPane(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{tabs.filter(
|
||||
(tab) =>
|
||||
tab.type !== "dashboard" &&
|
||||
!paneTabIds.includes(tab.id),
|
||||
).length === 0 && (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground/60">
|
||||
All tabs assigned
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -234,37 +288,51 @@ export function SplitScreenPanel({
|
||||
{t("newUi.sidebar.splitScreen.dragTabsHint")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 mt-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggingTabId(tab.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTabId(null);
|
||||
setDragOverPane(null);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-2.5 py-2 border cursor-grab active:cursor-grabbing select-none transition-colors ${
|
||||
draggingTabId === tab.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-medium flex-1 truncate">
|
||||
{tab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: tab.label}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
{tabs.map((tab) => {
|
||||
const paneIdx = paneTabIds.indexOf(tab.id);
|
||||
const isAssigned = paneIdx !== -1;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={!isAssigned}
|
||||
onDragStart={() => !isAssigned && setDraggingTabId(tab.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTabId(null);
|
||||
setDragOverPane(null);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-2.5 py-2 border select-none transition-colors ${
|
||||
isAssigned
|
||||
? "border-accent-brand/20 bg-accent-brand/5 opacity-60 cursor-default"
|
||||
: draggingTabId === tab.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand cursor-grabbing"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30 cursor-grab active:cursor-grabbing"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span className="text-xs font-medium flex-1 truncate">
|
||||
{tab.type === "dashboard"
|
||||
? t("newUi.sidebar.splitScreen.dashboard")
|
||||
: tab.label}
|
||||
</span>
|
||||
{isAssigned ? (
|
||||
<span className="text-[10px] text-accent-brand/60 font-mono shrink-0">
|
||||
{t("newUi.sidebar.splitScreen.alreadyAssigned", {
|
||||
index: paneIdx + 1,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -377,9 +377,13 @@ function PasswordChangeSection({
|
||||
export function UserProfilePanel({
|
||||
username,
|
||||
onLogout,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
}: {
|
||||
username?: string;
|
||||
onLogout?: () => void;
|
||||
userPrefs?: { reopenTabsOnLogin: boolean };
|
||||
onPrefsChange?: (prefs: { reopenTabsOnLogin: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const themeLabel: Record<ThemeId, string> = {
|
||||
@@ -448,9 +452,6 @@ export function UserProfilePanel({
|
||||
);
|
||||
|
||||
// Settings toggles — all backed by localStorage
|
||||
const [fileColorCoding, setFileColorCoding] = useState(
|
||||
() => localStorage.getItem("fileColorCoding") !== "false",
|
||||
);
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
||||
() => localStorage.getItem("commandAutocomplete") === "true",
|
||||
);
|
||||
@@ -464,13 +465,16 @@ export function UserProfilePanel({
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [sessionPersistence, setSessionPersistence] = useState(
|
||||
() => localStorage.getItem("enableTerminalSessionPersistence") !== "false",
|
||||
);
|
||||
const [showHostTags, setShowHostTags] = useState(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [hostTrayOnClick, setHostTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
);
|
||||
const [pinAppRail, setPinAppRail] = useState(
|
||||
() => localStorage.getItem("pinAppRail") === "true",
|
||||
);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(
|
||||
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
@@ -905,25 +909,6 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsFileManager")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.fileColorCoding")}
|
||||
description={t("newUi.sidebar.userProfile.fileColorCodingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={fileColorCoding}
|
||||
onChange={(v) => {
|
||||
setFileColorCoding(v);
|
||||
localStorage.setItem("fileColorCoding", v.toString());
|
||||
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsTerminal")}
|
||||
@@ -990,24 +975,23 @@ export function UserProfilePanel({
|
||||
"commandPaletteShortcutEnabled",
|
||||
v.toString(),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new Event("commandPaletteShortcutEnabledChanged"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.sessionPersistence")}
|
||||
description={t(
|
||||
"newUi.sidebar.userProfile.sessionPersistenceDesc",
|
||||
)}
|
||||
badge="BETA"
|
||||
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
|
||||
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={sessionPersistence}
|
||||
checked={userPrefs?.reopenTabsOnLogin ?? false}
|
||||
onChange={(v) => {
|
||||
setSessionPersistence(v);
|
||||
localStorage.setItem(
|
||||
"enableTerminalSessionPersistence",
|
||||
v.toString(),
|
||||
);
|
||||
onPrefsChange?.({ reopenTabsOnLogin: v });
|
||||
import("@/main-axios").then(({ saveUserPreferences }) => {
|
||||
saveUserPreferences({ reopenTabsOnLogin: v }).catch(() => {});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -1042,6 +1026,32 @@ export function UserProfilePanel({
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.hostTrayOnClick")}
|
||||
description={t("newUi.sidebar.userProfile.hostTrayOnClickDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={hostTrayOnClick}
|
||||
onChange={(v) => {
|
||||
setHostTrayOnClick(v);
|
||||
localStorage.setItem("hostTrayOnClick", v.toString());
|
||||
window.dispatchEvent(new Event("hostTrayOnClickChanged"));
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={pinAppRail}
|
||||
onChange={(v) => {
|
||||
setPinAppRail(v);
|
||||
localStorage.setItem("pinAppRail", v.toString());
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
|
||||
@@ -102,8 +102,8 @@ export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
if (versionChecking && !versionInfo) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6 z-50">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full items-center">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-background max-w-md w-full items-center">
|
||||
<div className="w-5 h-5 border-2 border-accent-brand border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("versionCheck.checkingUpdates")}
|
||||
</p>
|
||||
@@ -115,7 +115,7 @@ export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
if (!versionInfo || versionDismissed) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6 z-50">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-background max-w-md w-full">
|
||||
<p className="font-bold">{t("versionCheck.checkUpdates")}</p>
|
||||
{versionInfo && !versionDismissed && (
|
||||
<VersionAlert
|
||||
@@ -125,7 +125,7 @@ export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
)}
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold rounded-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
@@ -136,7 +136,7 @@ export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6 z-50">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-background max-w-md w-full">
|
||||
<p className="font-bold">{versionModalTitle}</p>
|
||||
<VersionAlert
|
||||
updateInfo={versionInfo}
|
||||
@@ -144,7 +144,7 @@ export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
/>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold rounded-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user