mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
3238 lines
110 KiB
TypeScript
3238 lines
110 KiB
TypeScript
/* eslint-disable react-refresh/only-export-components */
|
|
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import { toast } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Separator } from "@/components/separator";
|
|
import { Button } from "@/components/button";
|
|
import { Sheet, SheetContent } from "@/components/sheet";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Maximize2,
|
|
Minimize2,
|
|
PanelRight,
|
|
RotateCcw,
|
|
SquareArrowOutUpRight,
|
|
} from "lucide-react";
|
|
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
createRef,
|
|
lazy,
|
|
Suspense,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { resetPermissionsCache } from "@/hooks/use-permissions";
|
|
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
|
import { AppRail, type RailView } from "@/sidebar/AppRail";
|
|
import {
|
|
isCoreRailView,
|
|
railItemLabel,
|
|
promotableIds,
|
|
rightDockableIds,
|
|
useRailItems,
|
|
} from "@/sidebar/rail-items";
|
|
import { MultiPanelHint } from "@/sidebar/MultiPanelHint";
|
|
import { OnboardingDialog } from "@/onboarding/OnboardingDialog";
|
|
import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences";
|
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
|
import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView";
|
|
import { renderTabContent } from "@/shell/tabUtils";
|
|
import { TabBar } from "@/shell/TabBar";
|
|
import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts";
|
|
import { parseCustomKeybindings } from "@/api/open-tabs-api";
|
|
import { findMatchingKeybinding } from "@/lib/keybinding-match";
|
|
import type {
|
|
CustomKeybinding,
|
|
KeybindingActionType,
|
|
} from "@/types/keybindings";
|
|
|
|
// Shell surfaces that are not needed for first paint.
|
|
const CommandPalette = lazy(() =>
|
|
import("@/shell/CommandPalette").then((m) => ({
|
|
default: m.CommandPalette,
|
|
})),
|
|
);
|
|
const HostsPanel = lazy(() =>
|
|
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
|
|
);
|
|
const QuickConnectPanel = lazy(() =>
|
|
import("@/sidebar/QuickConnectPanel").then((m) => ({
|
|
default: m.QuickConnectPanel,
|
|
})),
|
|
);
|
|
const SerialPanel = lazy(() =>
|
|
import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })),
|
|
);
|
|
const SplitScreenPanel = lazy(() =>
|
|
import("@/sidebar/SplitScreenPanel").then((m) => ({
|
|
default: m.SplitScreenPanel,
|
|
})),
|
|
);
|
|
const AlertManager = lazy(() =>
|
|
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
|
|
default: m.AlertManager,
|
|
})),
|
|
);
|
|
|
|
// Secondary rail panels — load on first open, not with the shell critical path.
|
|
const SshToolsPanel = lazy(() =>
|
|
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
|
);
|
|
const CollabPanel = lazy(() =>
|
|
import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })),
|
|
);
|
|
const SnippetsPanel = lazy(() =>
|
|
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
|
|
);
|
|
const MacrosPanel = lazy(() =>
|
|
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
|
);
|
|
const HistoryPanel = lazy(() =>
|
|
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
|
|
);
|
|
const SessionLogsPanel = lazy(() =>
|
|
import("@/sidebar/SessionLogsPanel").then((m) => ({
|
|
default: m.SessionLogsPanel,
|
|
})),
|
|
);
|
|
// One screen behind both rail entries. It lazy-loads the profile and admin
|
|
// panels itself, so the shell no longer imports either directly.
|
|
const SettingsScreen = lazy(() =>
|
|
import("@/settings/SettingsScreen").then((m) => ({
|
|
default: m.SettingsScreen,
|
|
})),
|
|
);
|
|
const CredentialsPanel = lazy(() =>
|
|
import("@/sidebar/CredentialsPanel").then((m) => ({
|
|
default: m.CredentialsPanel,
|
|
})),
|
|
);
|
|
const PortForwardingPanel = lazy(() =>
|
|
import("@/sidebar/PortForwardingPanel").then((m) => ({
|
|
default: m.PortForwardingPanel,
|
|
})),
|
|
);
|
|
const TermixIdPanel = lazy(() =>
|
|
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
|
|
);
|
|
const ConnectionsPanel = lazy(() =>
|
|
import("@/sidebar/ConnectionsPanel").then((m) => ({
|
|
default: m.ConnectionsPanel,
|
|
})),
|
|
);
|
|
|
|
function SidebarPanelFallback() {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center p-6">
|
|
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
import type {
|
|
Tab,
|
|
TabType,
|
|
Host,
|
|
SplitMode,
|
|
HostFolder,
|
|
ThemeId,
|
|
FontSizeId,
|
|
SerialConfig,
|
|
WorkspacePayload,
|
|
} from "@/types/ui-types";
|
|
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
|
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import {
|
|
getSSHHosts,
|
|
getSSHFolders,
|
|
getUserInfo,
|
|
getOpenTabs,
|
|
addOpenTab,
|
|
deleteOpenTab,
|
|
patchOpenTab,
|
|
createSSHHost,
|
|
getActiveSessions,
|
|
getUserPreferences,
|
|
saveUserPreferences,
|
|
dismissDonationModal,
|
|
isElectron,
|
|
type UserPreferences,
|
|
type OpenTabRecord,
|
|
} from "@/main-axios";
|
|
import {
|
|
buildLayoutPayload,
|
|
remapSlotIds,
|
|
resolveLayoutTabTarget,
|
|
snapshotData,
|
|
} from "@/shell/shell-layout";
|
|
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
|
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
|
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
|
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
|
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
|
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
|
|
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
|
import { buildHostTree } from "@/sidebar/build-host-tree";
|
|
import {
|
|
assignTabsToSplit,
|
|
createSplitConfig,
|
|
releaseSplitTabs,
|
|
restoreSplitTabs,
|
|
serializeSplitTabs,
|
|
type PersistedSplitTab,
|
|
} from "@/shell/splitTabUtils";
|
|
import {
|
|
canRestoreTabType,
|
|
getTabType,
|
|
isPersistentTabType,
|
|
isSessionTabType,
|
|
type TabShellCallbacks,
|
|
} from "@/shell/tab-registry";
|
|
import { getPanel, usePanels } from "@/shell/panel-registry";
|
|
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
|
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
|
import {
|
|
notifyShellReady,
|
|
notifyTabsChanged,
|
|
resetShellBridge,
|
|
setShellCallbacks,
|
|
setShellHosts,
|
|
setShellLayoutProvider,
|
|
} from "@/plugin-host/shell-bridge";
|
|
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
|
|
|
export { buildHostTree } from "@/sidebar/build-host-tree";
|
|
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
|
|
|
// ─── AppShell ────────────────────────────────────────────────────────────────
|
|
|
|
export function AppShell({
|
|
username,
|
|
onLogout,
|
|
}: {
|
|
username: string;
|
|
onLogout: (options?: { manual?: boolean }) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const { setTheme } = useTheme();
|
|
// Re-render when plugins add or remove rail items, panels or tabs.
|
|
useRailItems();
|
|
const registeredPanels = usePanels();
|
|
const { settled: pluginsSettled } = usePluginStore();
|
|
const [tabs, setTabs] = useState<Tab[]>([
|
|
{
|
|
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");
|
|
// paneTabIds holds live tab.id values, which change on every restore, so we
|
|
// can't restore it from storage directly. It starts empty and gets filled in
|
|
// once by the reconciliation effect below, keyed off the stable instanceId
|
|
// values saved in termix_paneInstanceIds.
|
|
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
|
|
Array(6).fill(null),
|
|
);
|
|
const paneLayoutRestoredRef = useRef(false);
|
|
const splitTabsRestoredRef = useRef(false);
|
|
useEffect(() => {
|
|
paneTabIdsRef.current = paneTabIds;
|
|
}, [paneTabIds]);
|
|
const [rowSizes, setRowSizes] = useState<number[]>(
|
|
() => defaultSizes("none").rowSizes,
|
|
);
|
|
const [rowColSizes, setRowColSizes] = useState<RowColSizes>(
|
|
() => defaultSizes("none").rowColSizes,
|
|
);
|
|
const changeSplitMode = useCallback((mode: SplitMode) => {
|
|
setSplitMode(mode);
|
|
const d = defaultSizes(mode);
|
|
setRowSizes(d.rowSizes);
|
|
setRowColSizes(d.rowColSizes);
|
|
}, []);
|
|
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 allHostsRef = useRef(allHosts);
|
|
allHostsRef.current = allHosts;
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
// The standalone desktop backend still owns system settings such as the
|
|
// Tailscale API key, even though it has only one implicit user.
|
|
const showAdminUI = isAdmin;
|
|
const [userId, setUserId] = useState<string | null>(null);
|
|
const [showDonationModal, setShowDonationModal] = useState(false);
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
|
OpenTabRecord[]
|
|
>([]);
|
|
|
|
// First-run onboarding. The backend hands accounts that predate this feature
|
|
// an already-completed state, so only genuinely new users are interrupted.
|
|
const uiPrefs = useUiPreferencesContext();
|
|
const onboardingPending =
|
|
!!uiPrefs?.loaded &&
|
|
uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION;
|
|
|
|
/**
|
|
* Both onboarding entry points wait for plugins first, since plugins add
|
|
* steps of their own (the AI step, feature cards).
|
|
*/
|
|
const loadOnboardingContext = useCallback(async () => {
|
|
const { settledPromise } = await import("@/plugin-host/plugin-store");
|
|
await settledPromise();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!username || !onboardingPending) return;
|
|
let cancelled = false;
|
|
loadOnboardingContext().finally(() => {
|
|
if (!cancelled) setShowOnboarding(true);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [username, onboardingPending, loadOnboardingContext]);
|
|
|
|
// "Run setup again" from settings.
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
loadOnboardingContext().finally(() => setShowOnboarding(true));
|
|
};
|
|
window.addEventListener("termix:open-onboarding", handler);
|
|
return () => window.removeEventListener("termix:open-onboarding", handler);
|
|
}, [loadOnboardingContext]);
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [railView, setRailView] = useState<RailView>("hosts");
|
|
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] =
|
|
useState(false);
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem("termix_sidebarWidth");
|
|
return saved ? parseInt(saved, 10) : 291;
|
|
});
|
|
const [sidebarDragging, setSidebarDragging] = useState(false);
|
|
const [sidebarEditing, setSidebarEditing] = useState(false);
|
|
const [settingsFullscreen, setSettingsFullscreen] = useState(false);
|
|
|
|
// Right dock — a second panel column so reference panels like history can
|
|
// stay visible while the left sidebar is used for something else.
|
|
const [rightRailView, setRightRailView] = useState<RailView | null>(() => {
|
|
const saved = localStorage.getItem("termix_rightRailView");
|
|
return saved && rightDockableIds().includes(saved)
|
|
? (saved as RailView)
|
|
: null;
|
|
});
|
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
|
|
const saved = localStorage.getItem("termix_rightSidebarWidth");
|
|
return saved ? parseInt(saved, 10) : 291;
|
|
});
|
|
const [rightSidebarDragging, setRightSidebarDragging] = useState(false);
|
|
// Remembers the last panel shown in the dock so the tab bar toggle can bring
|
|
// it back instead of always falling back to the same default.
|
|
const lastRightRailViewRef = useRef<string | null>(
|
|
localStorage.getItem("termix_lastRightRailView"),
|
|
);
|
|
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
|
() => !!document.fullscreenElement,
|
|
);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
|
}, [sidebarWidth]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
|
|
}, [rightSidebarWidth]);
|
|
|
|
useEffect(() => {
|
|
if (rightRailView) {
|
|
localStorage.setItem("termix_rightRailView", rightRailView);
|
|
lastRightRailViewRef.current = rightRailView;
|
|
localStorage.setItem("termix_lastRightRailView", rightRailView);
|
|
} else {
|
|
localStorage.removeItem("termix_rightRailView");
|
|
}
|
|
}, [rightRailView]);
|
|
|
|
useEffect(() => {
|
|
if (!splitTabsRestoredRef.current) return;
|
|
localStorage.setItem(
|
|
"termix_splitTabs",
|
|
JSON.stringify(serializeSplitTabs(tabs)),
|
|
);
|
|
}, [tabs]);
|
|
|
|
const isMobile = useIsMobile();
|
|
const isSettingsView =
|
|
railView === "user-profile" || railView === "admin-settings";
|
|
|
|
useEffect(() => {
|
|
if (!settingsFullscreen) return;
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setSettingsFullscreen(false);
|
|
};
|
|
window.addEventListener("keydown", closeOnEscape);
|
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
}, [settingsFullscreen]);
|
|
|
|
useEffect(() => {
|
|
if (!isSettingsView) setSettingsFullscreen(false);
|
|
}, [isSettingsView]);
|
|
|
|
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
sidebarOpenBeforeMobile.current = sidebarOpen;
|
|
setSidebarOpen(false);
|
|
} else {
|
|
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
|
}
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
getUserInfo()
|
|
.then((info) => {
|
|
setIsAdmin(info.is_admin);
|
|
setUserId(info.userId);
|
|
setShowDonationModal(!!info.show_donation_modal);
|
|
})
|
|
.catch(() => setIsAdmin(false));
|
|
}, []);
|
|
|
|
// Plugin frontends register the tabs, panels and actions that a saved
|
|
// session may need, so they load before tabs are restored. On logout every
|
|
// plugin is deactivated, since the next user may see different ones.
|
|
useEffect(() => {
|
|
startPluginRuntime().catch(() => {});
|
|
return () => {
|
|
void stopPluginRuntime();
|
|
resetShellBridge();
|
|
};
|
|
}, []);
|
|
|
|
const handleDismissDonationModal = useCallback(() => {
|
|
setShowDonationModal(false);
|
|
dismissDonationModal().catch(() => {});
|
|
}, []);
|
|
|
|
const toggleAppFullscreen = useCallback(async () => {
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
return;
|
|
}
|
|
|
|
if (!document.fullscreenEnabled) {
|
|
toast.error("Fullscreen is not supported by this browser");
|
|
return;
|
|
}
|
|
|
|
await document.documentElement.requestFullscreen();
|
|
} catch {
|
|
toast.error("Unable to toggle fullscreen mode");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsAppFullscreen(!!document.fullscreenElement);
|
|
};
|
|
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
return () =>
|
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
}, []);
|
|
|
|
const lastShiftTime = useRef(0);
|
|
const tabsRef = useRef(tabs);
|
|
const activeTabIdRef = useRef(activeTabId);
|
|
const closeActiveTabRef = useRef<() => void>(() => {});
|
|
const globalKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
|
const splitModeRef = useRef(splitMode);
|
|
const focusedPaneIndexRef = useRef<number | null>(null);
|
|
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
|
|
Array(6).fill(null),
|
|
);
|
|
const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null));
|
|
useEffect(() => {
|
|
tabsRef.current = tabs;
|
|
}, [tabs]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = () => {
|
|
getUserPreferences()
|
|
.then((prefs) => {
|
|
if (cancelled) return;
|
|
globalKeybindingsRef.current = parseCustomKeybindings(
|
|
prefs.customKeybindings,
|
|
).filter(
|
|
(binding) =>
|
|
binding.enabled &&
|
|
[
|
|
"nextTab",
|
|
"previousTab",
|
|
"openCommandPalette",
|
|
"reconnectSession",
|
|
].includes(binding.action.type),
|
|
);
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
load();
|
|
window.addEventListener("customKeybindingsChanged", load);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener("customKeybindingsChanged", load);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const runAction = (type: KeybindingActionType) => {
|
|
if (type === "openCommandPalette") {
|
|
setCommandPaletteOpen(true);
|
|
return;
|
|
}
|
|
if (type === "reconnectSession") {
|
|
const tabId = activeTabIdRef.current;
|
|
if (!tabId) return;
|
|
const termRef = terminalRefs.current.get(tabId);
|
|
(
|
|
termRef?.current as
|
|
import("@/features/terminal/Terminal").TerminalHandle | null
|
|
)?.reconnect();
|
|
return;
|
|
}
|
|
const currentTabs = tabsRef.current;
|
|
if (currentTabs.length < 2) return;
|
|
const index = currentTabs.findIndex(
|
|
(tab) => tab.id === activeTabIdRef.current,
|
|
);
|
|
const offset = type === "nextTab" ? 1 : -1;
|
|
const next = (index + offset + currentTabs.length) % currentTabs.length;
|
|
setActiveTabId(currentTabs[next].id);
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest("[data-keybinding-recorder]")
|
|
)
|
|
return;
|
|
const binding = findMatchingKeybinding(
|
|
event,
|
|
globalKeybindingsRef.current,
|
|
);
|
|
if (!binding) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
runAction(binding.action.type);
|
|
};
|
|
const handleAction = (event: Event) =>
|
|
runAction(
|
|
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
|
|
);
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
window.addEventListener("termix:global-keybinding", handleAction);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
window.removeEventListener("termix:global-keybinding", handleAction);
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
activeTabIdRef.current = activeTabId;
|
|
}, [activeTabId]);
|
|
useEffect(() => {
|
|
return window.electronAPI?.onCloseActiveTab?.(() => {
|
|
if (dispatchCtrlW(document.activeElement)) return;
|
|
closeActiveTabRef.current();
|
|
});
|
|
}, []);
|
|
const skipSplitSyncRef = useRef(false);
|
|
const activeTabAvailable = tabs.some((tab) => tab.id === activeTabId);
|
|
useEffect(() => {
|
|
const active = tabs.find((tab) => tab.id === activeTabId);
|
|
const config = active?.type === "split-screen" ? active.splitConfig : null;
|
|
skipSplitSyncRef.current = true;
|
|
if (!config) {
|
|
setSplitMode("none");
|
|
setPaneTabIds(Array(6).fill(null));
|
|
setFocusedPaneIndex(null);
|
|
return;
|
|
}
|
|
setSplitMode(config.mode);
|
|
setPaneTabIds(config.paneTabIds);
|
|
setRowSizes(config.rowSizes);
|
|
setRowColSizes(config.rowColSizes);
|
|
setFocusedPaneIndex(0);
|
|
}, [activeTabId, activeTabAvailable]);
|
|
|
|
useEffect(() => {
|
|
if (skipSplitSyncRef.current) {
|
|
skipSplitSyncRef.current = false;
|
|
return;
|
|
}
|
|
if (splitMode === "none") return;
|
|
setTabs((prev) => {
|
|
const active = prev.find((tab) => tab.id === activeTabId);
|
|
if (active?.type !== "split-screen") return prev;
|
|
const config = createSplitConfig(splitMode, paneTabIds, {
|
|
rowSizes,
|
|
rowColSizes,
|
|
});
|
|
const updated = prev.map((tab) =>
|
|
tab.id === activeTabId ? { ...tab, splitConfig: config } : tab,
|
|
);
|
|
return assignTabsToSplit(updated, activeTabId, paneTabIds);
|
|
});
|
|
}, [activeTabId, paneTabIds, rowColSizes, rowSizes, splitMode]);
|
|
// Panels like history and snippets act on "the terminal you're working in".
|
|
// Once those panels can themselves be the active tab, activeTabId points at
|
|
// the panel and the lookup misses, so remember the last terminal instead.
|
|
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
|
|
useEffect(() => {
|
|
const active = tabs.find((t) => t.id === activeTabId);
|
|
if (active?.type === "terminal") setLastTerminalTabId(active.id);
|
|
}, [activeTabId, tabs]);
|
|
useEffect(() => {
|
|
splitModeRef.current = splitMode;
|
|
}, [splitMode]);
|
|
useEffect(() => {
|
|
focusedPaneIndexRef.current = focusedPaneIndex;
|
|
}, [focusedPaneIndex]);
|
|
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
|
useState<boolean>(() => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
return v !== null ? v === "true" : true;
|
|
});
|
|
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
|
|
() => localStorage.getItem("showTabNumbers") === "true",
|
|
);
|
|
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
|
new Map(),
|
|
);
|
|
const [paneContentEls, setPaneContentEls] = useState<
|
|
(HTMLDivElement | null)[]
|
|
>(Array(6).fill(null));
|
|
useEffect(() => {
|
|
paneContentElsRef.current = paneContentEls;
|
|
}, [paneContentEls]);
|
|
|
|
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
|
// We always portal each tab's content into its own node, then move that node between
|
|
// the normal-view container and the pane container via vanilla DOM so React's portal
|
|
// target never changes (changing the target causes a remount).
|
|
const tabNodesRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const normalViewRef = useRef<HTMLDivElement>(null);
|
|
// Tab id the enter animation has already played for, so a re-render while
|
|
// the tab stays active (there can be several right after a switch) doesn't
|
|
// replay it — only a genuine switch to a different tab should.
|
|
const lastAnimatedTabIdRef = useRef<string | null>(null);
|
|
|
|
const getTabNode = useCallback((tabId: string, isTerminal: boolean) => {
|
|
if (!tabNodesRef.current.has(tabId)) {
|
|
const el = document.createElement("div");
|
|
el.style.position = "absolute";
|
|
el.style.inset = "0";
|
|
el.style.overflow = "hidden";
|
|
if (!isTerminal) el.classList.add("bg-background");
|
|
tabNodesRef.current.set(tabId, el);
|
|
}
|
|
return tabNodesRef.current.get(tabId)!;
|
|
}, []);
|
|
|
|
// Portal render order for tab content, kept independent of the tab bar's
|
|
// visual order. Reordering tabs in the bar reorders `tabs`, and mapping
|
|
// that array directly to portals reshuffles the Suspense-wrapped portal
|
|
// children's sibling order in the fiber tree — React then runs its
|
|
// Offscreen disconnect/reconnect pass on the ones that moved, which tears
|
|
// down and rebuilds every passive effect underneath (including
|
|
// react-xtermjs's terminal-creation effect), dropping the live terminal
|
|
// and its WebSocket. Portal position doesn't need to track tab order at
|
|
// all, so we only ever append new ids and drop closed ones here.
|
|
const portalOrderRef = useRef<string[]>([]);
|
|
{
|
|
const liveIds = new Set(tabs.map((t) => t.id));
|
|
portalOrderRef.current = portalOrderRef.current.filter((id) =>
|
|
liveIds.has(id),
|
|
);
|
|
const known = new Set(portalOrderRef.current);
|
|
for (const tab of tabs) {
|
|
if (!known.has(tab.id)) portalOrderRef.current.push(tab.id);
|
|
}
|
|
}
|
|
const tabsByPortalOrder = portalOrderRef.current
|
|
.map((id) => tabs.find((t) => t.id === id))
|
|
.filter((t): t is Tab => t !== undefined);
|
|
|
|
const onPaneContentRef = useCallback(
|
|
(paneIndex: number, el: HTMLDivElement | null) => {
|
|
setPaneContentEls((prev) => {
|
|
if (prev[paneIndex] === el) return prev;
|
|
const next = [...prev];
|
|
next[paneIndex] = el;
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Titles come from the shared rail definitions so they stay translated and
|
|
// in step with the rail itself.
|
|
const sidebarTitle = (view: RailView): string => railItemLabel(view, t);
|
|
|
|
// Double-shift or Ctrl+K opens the command palette. Double-shift alone was
|
|
// hard to discover.
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (isShiftKey(e) && !e.repeat) {
|
|
const now = Date.now();
|
|
if (now - lastShiftTime.current < 300 && commandPaletteShortcutEnabled)
|
|
setCommandPaletteOpen((prev) => !prev);
|
|
lastShiftTime.current = now;
|
|
}
|
|
if (
|
|
(e.ctrlKey || e.metaKey) &&
|
|
!e.shiftKey &&
|
|
!e.altKey &&
|
|
e.code === "KeyK" &&
|
|
commandPaletteShortcutEnabled
|
|
) {
|
|
e.preventDefault();
|
|
setCommandPaletteOpen((prev) => !prev);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [commandPaletteShortcutEnabled]);
|
|
|
|
// Ctrl+Shift+E toggles between the two most recent sidebar panels.
|
|
const previousRailViewRef = useRef<RailView | null>(null);
|
|
const currentRailViewRef = useRef(railView);
|
|
useEffect(() => {
|
|
if (currentRailViewRef.current !== railView) {
|
|
previousRailViewRef.current = currentRailViewRef.current;
|
|
currentRailViewRef.current = railView;
|
|
}
|
|
}, [railView]);
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return;
|
|
e.preventDefault();
|
|
const previous = previousRailViewRef.current;
|
|
if (!sidebarOpen) {
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
if (previous && previous !== railView) handleRailClick(previous);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [railView, sidebarOpen]);
|
|
|
|
// Split-screen and tab navigation hotkeys
|
|
// Also registered in globalShortcutHandler so xterm can invoke directly
|
|
// without going through synthetic DOM events (which are unreliable).
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
if (e.code === "KeyF") {
|
|
e.preventDefault();
|
|
toggleAppFullscreen();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ctrl+Shift+\ — toggle 2-way split (side by side)
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") {
|
|
e.preventDefault();
|
|
if (splitModeRef.current !== "none") {
|
|
selectSplitMode("none");
|
|
} else {
|
|
selectSplitMode("2-way");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom)
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Minus") {
|
|
e.preventDefault();
|
|
if (splitModeRef.current !== "none") {
|
|
selectSplitMode("none");
|
|
} else {
|
|
selectSplitMode("3-way-horizontal");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Alt+Arrow — navigate between panes in split mode
|
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
if (
|
|
e.code === "ArrowLeft" ||
|
|
e.code === "ArrowRight" ||
|
|
e.code === "ArrowUp" ||
|
|
e.code === "ArrowDown"
|
|
) {
|
|
if (splitModeRef.current === "none") return;
|
|
const count = PANE_COUNTS[splitModeRef.current];
|
|
if (count < 2) return;
|
|
e.preventDefault();
|
|
const current = focusedPaneIndexRef.current ?? 0;
|
|
const mode = splitModeRef.current;
|
|
const dir = e.code;
|
|
|
|
// Layout-aware navigation maps: [left, right, up, down] per pane index.
|
|
// null means no movement in that direction.
|
|
const navMap: Record<string, (number | null)[][]> = {
|
|
"2-way": [
|
|
[null, 1, null, null],
|
|
[0, null, null, null],
|
|
],
|
|
"2-way-horizontal": [
|
|
[null, null, null, 1],
|
|
[null, null, 0, null],
|
|
],
|
|
"3-way": [
|
|
[null, 1, null, null],
|
|
[0, null, null, 2],
|
|
[0, null, 1, null],
|
|
],
|
|
"3-way-horizontal": [
|
|
[null, 1, null, 2],
|
|
[0, null, null, 2],
|
|
[null, null, 0, null],
|
|
],
|
|
"4-way": [
|
|
[null, 1, null, 2],
|
|
[0, null, null, 3],
|
|
[null, 3, 0, null],
|
|
[2, null, 1, null],
|
|
],
|
|
"5-way": [
|
|
[null, 1, null, 3],
|
|
[0, 2, null, 4],
|
|
[1, null, null, 4],
|
|
[null, 4, 0, null],
|
|
[3, null, 1, null],
|
|
],
|
|
"6-way": [
|
|
[null, 1, null, 3],
|
|
[0, 2, null, 4],
|
|
[1, null, null, 5],
|
|
[null, 4, 0, null],
|
|
[3, 5, 1, null],
|
|
[4, null, 2, null],
|
|
],
|
|
};
|
|
|
|
const paneNav = navMap[mode]?.[current];
|
|
const dirIndex =
|
|
{ ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3 }[dir] ??
|
|
-1;
|
|
const next = paneNav?.[dirIndex] ?? null;
|
|
if (next === null) return;
|
|
|
|
focusedPaneIndexRef.current = next;
|
|
setFocusedPaneIndex(next);
|
|
// Physically move DOM focus into the target pane's terminal
|
|
const tabId = paneTabIdsRef.current[next];
|
|
if (tabId) {
|
|
const termRef = terminalRefs.current.get(tabId);
|
|
(
|
|
termRef?.current as
|
|
import("@/features/terminal/Terminal").TerminalHandle | null
|
|
)?.focus();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Cmd+1..9 on macOS, Alt+1..9 elsewhere — jump directly to the tab at that position
|
|
const tabDigit = getTabJumpDigit(e);
|
|
if (tabDigit !== null) {
|
|
const currentTabs = tabsRef.current;
|
|
const index = tabDigit - 1;
|
|
if (index < currentTabs.length) {
|
|
e.preventDefault();
|
|
setActiveTabId(currentTabs[index].id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+] / Ctrl+Shift+[ — cycle through open tabs (] = next, [ = previous)
|
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
|
e.preventDefault();
|
|
const currentTabs = tabsRef.current;
|
|
if (currentTabs.length < 2) return;
|
|
const currentId = activeTabIdRef.current;
|
|
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
|
const next =
|
|
e.code === "BracketRight"
|
|
? (idx + 1) % currentTabs.length
|
|
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
|
setActiveTabId(currentTabs[next].id);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
globalShortcutHandler.current = handleKeyDown;
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
globalShortcutHandler.current = null;
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
|
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
|
};
|
|
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
|
return () =>
|
|
window.removeEventListener(
|
|
"commandPaletteShortcutEnabledChanged",
|
|
handler,
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true");
|
|
};
|
|
window.addEventListener("showTabNumbersChanged", handler);
|
|
return () => window.removeEventListener("showTabNumbersChanged", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handle = (event: Event) => {
|
|
const manual =
|
|
event instanceof CustomEvent &&
|
|
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
|
// Drop the cached grants so the next user never inherits them.
|
|
resetPermissionsCache();
|
|
onLogout(manual ? { manual: true } : undefined);
|
|
};
|
|
window.addEventListener("termix:logout", handle);
|
|
return () => window.removeEventListener("termix:logout", handle);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const handleSessionExpired = () => onLogout();
|
|
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
|
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
|
}, [onLogout]);
|
|
|
|
useEffect(() => {
|
|
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
if (!activeTab?.terminalRef) return;
|
|
let innerRafId: number;
|
|
const outerRafId = requestAnimationFrame(() => {
|
|
innerRafId = requestAnimationFrame(() => {
|
|
const ref = activeTab.terminalRef?.current;
|
|
ref?.fit?.();
|
|
ref?.notifyResize?.();
|
|
ref?.refresh?.();
|
|
});
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(outerRafId);
|
|
cancelAnimationFrame(innerRafId);
|
|
};
|
|
}, [activeTabId]);
|
|
|
|
useEffect(() => {
|
|
const handleDegraded = () => {
|
|
toast.loading(t("common.connectionDegraded"), {
|
|
id: "db-connection-degraded",
|
|
duration: Infinity,
|
|
dismissible: false,
|
|
action: {
|
|
label: t("common.reload"),
|
|
onClick: () => window.location.reload(),
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRestored = () => {
|
|
toast.dismiss("db-connection-degraded");
|
|
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
|
};
|
|
|
|
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
|
|
|
return () => {
|
|
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
|
dbHealthMonitor.off(
|
|
"database-connection-degraded-cleared",
|
|
handleRestored,
|
|
);
|
|
};
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
getUserPreferences()
|
|
.then((prefs) => {
|
|
const loginLanguage = consumeLoginLanguage();
|
|
setUserPrefs(prefs);
|
|
if (prefs.storageMode === "cloud") {
|
|
// Persist the current browser values before overwriting, so any tab can restore them
|
|
if (!localStorage.getItem("termix-local-snapshot")) {
|
|
const SNAPSHOT_KEYS = [
|
|
"termix-accent",
|
|
"termix-font-size",
|
|
"termix-ui-font",
|
|
"i18nextLng",
|
|
"commandAutocomplete",
|
|
"commandPaletteShortcutEnabled",
|
|
"showHostTags",
|
|
"hostTrayOnClick",
|
|
"pinAppRail",
|
|
"expandAppRailOnHover",
|
|
"defaultSnippetFoldersCollapsed",
|
|
"confirmSnippetExecution",
|
|
"disableUpdateCheck",
|
|
"confirmTabClose",
|
|
"hiddenRailTabs",
|
|
];
|
|
const snap: Record<string, string | null> = {
|
|
__theme: localStorage.getItem("termix-theme"),
|
|
};
|
|
for (const key of SNAPSHOT_KEYS)
|
|
snap[key] = localStorage.getItem(key);
|
|
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
|
}
|
|
if (prefs.theme) setTheme(prefs.theme as ThemeId);
|
|
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
|
if (prefs.accentColor) {
|
|
localStorage.setItem("termix-accent", prefs.accentColor);
|
|
applyAccentColor(prefs.accentColor);
|
|
}
|
|
const preferredLanguage = loginLanguage ?? prefs.language;
|
|
if (preferredLanguage && preferredLanguage !== i18n.language) {
|
|
void changeAppLanguage(preferredLanguage);
|
|
}
|
|
if (loginLanguage && loginLanguage !== prefs.language) {
|
|
void saveUserPreferences({ language: loginLanguage });
|
|
}
|
|
if (
|
|
prefs.commandAutocomplete !== null &&
|
|
prefs.commandAutocomplete !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"commandAutocomplete",
|
|
String(prefs.commandAutocomplete),
|
|
);
|
|
if (
|
|
prefs.commandPaletteEnabled !== null &&
|
|
prefs.commandPaletteEnabled !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"commandPaletteShortcutEnabled",
|
|
String(prefs.commandPaletteEnabled),
|
|
);
|
|
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
|
|
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
|
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
|
}
|
|
if (
|
|
prefs.hostTrayOnClick !== null &&
|
|
prefs.hostTrayOnClick !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"hostTrayOnClick",
|
|
String(prefs.hostTrayOnClick),
|
|
);
|
|
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
|
|
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
|
window.dispatchEvent(new Event("pinAppRailChanged"));
|
|
}
|
|
if (
|
|
prefs.expandAppRailOnHover !== null &&
|
|
prefs.expandAppRailOnHover !== undefined
|
|
) {
|
|
localStorage.setItem(
|
|
"expandAppRailOnHover",
|
|
String(prefs.expandAppRailOnHover),
|
|
);
|
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
|
}
|
|
if (
|
|
prefs.foldersCollapsed !== null &&
|
|
prefs.foldersCollapsed !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"defaultSnippetFoldersCollapsed",
|
|
String(prefs.foldersCollapsed),
|
|
);
|
|
if (
|
|
prefs.confirmSnippetExecution !== null &&
|
|
prefs.confirmSnippetExecution !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"confirmSnippetExecution",
|
|
String(prefs.confirmSnippetExecution),
|
|
);
|
|
if (
|
|
prefs.disableUpdateCheck !== null &&
|
|
prefs.disableUpdateCheck !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"disableUpdateCheck",
|
|
String(prefs.disableUpdateCheck),
|
|
);
|
|
if (
|
|
prefs.confirmTabClose !== null &&
|
|
prefs.confirmTabClose !== undefined
|
|
)
|
|
localStorage.setItem(
|
|
"confirmTabClose",
|
|
String(prefs.confirmTabClose),
|
|
);
|
|
if (
|
|
prefs.hiddenRailTabs !== null &&
|
|
prefs.hiddenRailTabs !== undefined
|
|
) {
|
|
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
|
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setUserPrefsLoaded(true));
|
|
}, []);
|
|
|
|
// Load real hosts from API
|
|
const loadHosts = useCallback(async () => {
|
|
try {
|
|
const [raw, folders] = await Promise.all([
|
|
getSSHHosts(),
|
|
getSSHFolders().catch(() => []),
|
|
]);
|
|
const converted = raw.map(sshHostToHost);
|
|
setAllHosts(converted);
|
|
const folderMeta = new Map<
|
|
string,
|
|
{
|
|
color?: string;
|
|
icon?: string;
|
|
credentialId?: number | null;
|
|
sortOrder?: number | null;
|
|
}
|
|
>();
|
|
for (const f of folders) {
|
|
folderMeta.set(f.name, {
|
|
color: f.color ?? undefined,
|
|
icon: f.icon ?? undefined,
|
|
credentialId: f.credentialId ?? null,
|
|
sortOrder: f.sortOrder ?? null,
|
|
});
|
|
}
|
|
setRealHostTree(buildHostTree(raw, folderMeta));
|
|
} catch {
|
|
// Keep empty state on error
|
|
} finally {
|
|
setHostsLoading(false);
|
|
setHostsLoaded(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadHosts();
|
|
}, [loadHosts]);
|
|
|
|
useEffect(() => {
|
|
const onHostsChanged = () => {
|
|
void loadHosts();
|
|
};
|
|
window.addEventListener("termix:hosts-changed", onHostsChanged);
|
|
window.addEventListener("ssh-hosts:changed", onHostsChanged);
|
|
window.addEventListener("hosts:refresh", onHostsChanged);
|
|
return () => {
|
|
window.removeEventListener("termix:hosts-changed", onHostsChanged);
|
|
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
|
|
window.removeEventListener("hosts:refresh", onHostsChanged);
|
|
};
|
|
}, [loadHosts]);
|
|
|
|
// The Electron main process runs remote sync (pull/push hosts and
|
|
// credentials with a connected Termix server) on its own timer, entirely
|
|
// outside any renderer-initiated action, so nothing normally dispatches
|
|
// the termix:hosts-changed / termix:credentials-changed events that
|
|
// panels rely on to refetch. Without this, newly-synced hosts/credentials
|
|
// only show up after a manual refresh or app restart.
|
|
useEffect(() => {
|
|
if (!isElectron()) return;
|
|
let wasSyncing = false;
|
|
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
|
|
(status: { syncing: boolean; lastError: string | null }) => {
|
|
const justFinished = wasSyncing && !status.syncing && !status.lastError;
|
|
wasSyncing = status.syncing;
|
|
if (justFinished) {
|
|
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
|
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
|
}
|
|
},
|
|
);
|
|
return () => unsubscribe?.();
|
|
}, []);
|
|
|
|
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
|
useEffect(() => {
|
|
if (allHosts.length === 0) return;
|
|
setTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.host
|
|
? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host }
|
|
: t,
|
|
),
|
|
);
|
|
}, [allHosts]);
|
|
|
|
// Let HostManager trigger tab opens via custom event
|
|
useEffect(() => {
|
|
const handle = (e: Event) => {
|
|
const { hostId, type } = (
|
|
e as CustomEvent<{ hostId: string; type?: TabType }>
|
|
).detail;
|
|
const host = allHosts.find((h) => h.id === hostId);
|
|
if (host) connectHost(host, type);
|
|
};
|
|
window.addEventListener("termix:open-tab", handle);
|
|
return () => window.removeEventListener("termix:open-tab", handle);
|
|
}, [allHosts]);
|
|
|
|
function buildWorkspacePayload(): WorkspacePayload {
|
|
return buildLayoutPayload({
|
|
tabs,
|
|
activeTabId,
|
|
splitMode,
|
|
paneTabIds,
|
|
rowSizes,
|
|
rowColSizes,
|
|
sidebar: {
|
|
left: { view: railView, open: sidebarOpen, width: sidebarWidth },
|
|
right: {
|
|
view: rightRailView,
|
|
open: rightRailView !== null,
|
|
width: rightSidebarWidth,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function applyLayout(
|
|
payload: WorkspacePayload,
|
|
name: string,
|
|
): Promise<{ skipped: string[] }> {
|
|
// Tear down the current arrangement the same way an individual tab close does.
|
|
for (const tab of [...tabsRef.current]) {
|
|
doCloseTab(tab.id);
|
|
}
|
|
|
|
const slotIdToNewTabId = new Map<string, string>();
|
|
const skippedTabs: string[] = [];
|
|
|
|
for (const snapshot of payload.tabs) {
|
|
const target = resolveLayoutTabTarget(snapshot, allHostsRef.current);
|
|
|
|
if (target.kind === "skip") {
|
|
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
|
|
continue;
|
|
}
|
|
|
|
if (target.kind === "serial" && snapshot.serialConfig) {
|
|
const newTabId = openSerialTab(snapshot.serialConfig);
|
|
slotIdToNewTabId.set(snapshot.slotId, newTabId);
|
|
continue;
|
|
}
|
|
|
|
if (target.kind === "singleton") {
|
|
openSingletonTab(
|
|
snapshot.type,
|
|
undefined,
|
|
target.host,
|
|
snapshotData(snapshot),
|
|
);
|
|
slotIdToNewTabId.set(snapshot.slotId, snapshot.type);
|
|
continue;
|
|
}
|
|
|
|
if (target.kind === "host") {
|
|
const newTabId = openTab(target.host, snapshot.type, {
|
|
instanceId: crypto.randomUUID(),
|
|
restoredSessionId: null,
|
|
savedLabel: snapshot.customLabel ?? snapshot.label,
|
|
initialFilePath: snapshot.initialFilePath,
|
|
initialPath: snapshot.initialPath,
|
|
});
|
|
slotIdToNewTabId.set(snapshot.slotId, newTabId);
|
|
}
|
|
}
|
|
|
|
const restoredPaneIds = remapSlotIds(payload.paneTabIds, slotIdToNewTabId);
|
|
let restoredSplitTabId: string | null = null;
|
|
if (payload.splitMode !== "none" && restoredPaneIds.some(Boolean)) {
|
|
const instanceId = crypto.randomUUID();
|
|
restoredSplitTabId = `split-${instanceId}`;
|
|
const splitTab: Tab = {
|
|
id: restoredSplitTabId,
|
|
instanceId,
|
|
type: "split-screen",
|
|
label: name,
|
|
openedAt: Date.now(),
|
|
splitConfig: createSplitConfig(
|
|
payload.splitMode,
|
|
restoredPaneIds,
|
|
payload,
|
|
),
|
|
};
|
|
setTabs((prev) =>
|
|
assignTabsToSplit([...prev, splitTab], splitTab.id, restoredPaneIds),
|
|
);
|
|
}
|
|
|
|
const activeId = payload.activeSlotId
|
|
? (slotIdToNewTabId.get(payload.activeSlotId) ?? "dashboard")
|
|
: "dashboard";
|
|
setActiveTabId(restoredSplitTabId ?? activeId);
|
|
|
|
// Older payloads predate the sidebar field, so leave the docks alone then.
|
|
const sidebar = payload.sidebar;
|
|
if (sidebar) {
|
|
if (sidebar.left.view) setRailView(sidebar.left.view as RailView);
|
|
setSidebarOpen(sidebar.left.open);
|
|
if (sidebar.left.width) setSidebarWidth(sidebar.left.width);
|
|
|
|
const right = sidebar.right.open ? sidebar.right.view : null;
|
|
setRightRailView(
|
|
right && rightDockableIds().includes(right)
|
|
? (right as RailView)
|
|
: null,
|
|
);
|
|
if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width);
|
|
}
|
|
|
|
return { skipped: skippedTabs };
|
|
}
|
|
|
|
// 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(() => {
|
|
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
|
|
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) 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) =>
|
|
isPersistentTabType(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;
|
|
if (!canRestoreTabType(saved.tabType, host)) 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;
|
|
|
|
const isCustomLabel =
|
|
host &&
|
|
saved.label !== host.name &&
|
|
!/^.+ \(\d+\)$/.test(saved.label);
|
|
|
|
restoredTabs.push({
|
|
id: tabId,
|
|
instanceId: saved.id,
|
|
type: saved.tabType as TabType,
|
|
label: saved.label,
|
|
customLabel: isCustomLabel ? saved.label : undefined,
|
|
host,
|
|
openedAt: new Date(saved.createdAt).getTime(),
|
|
restoredSessionId,
|
|
terminalRef: isSessionTabType(saved.tabType)
|
|
? 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();
|
|
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
|
|
|
|
// Plugins that act once the session is back (the workspaces plugin applies
|
|
// a default workspace) wait for this.
|
|
useEffect(() => {
|
|
if (tabsReady) notifyShellReady();
|
|
}, [tabsReady]);
|
|
|
|
// Restore named split tabs once their child sessions have stable live ids. The old
|
|
// singleton keys are migrated once into Split #1 so existing layouts are preserved.
|
|
useEffect(() => {
|
|
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
|
paneLayoutRestoredRef.current = true;
|
|
|
|
try {
|
|
const savedSplitTabs = JSON.parse(
|
|
localStorage.getItem("termix_splitTabs") ?? "[]",
|
|
) as PersistedSplitTab[];
|
|
if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) {
|
|
setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev));
|
|
splitTabsRestoredRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const savedInstanceIds: (string | null)[] = JSON.parse(
|
|
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
|
);
|
|
const savedMode = localStorage.getItem("termix_splitMode") as SplitMode;
|
|
if (
|
|
!Array.isArray(savedInstanceIds) ||
|
|
!savedMode ||
|
|
savedMode === "none"
|
|
) {
|
|
splitTabsRestoredRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const restored = savedInstanceIds.map((instanceId) => {
|
|
if (instanceId == null) return null;
|
|
return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
|
|
});
|
|
if (restored.some((id) => id != null)) {
|
|
let sizes = defaultSizes(savedMode);
|
|
try {
|
|
const savedSizes = JSON.parse(
|
|
localStorage.getItem("termix_paneSizes") ?? "null",
|
|
) as { rowSizes?: number[]; rowColSizes?: RowColSizes } | null;
|
|
if (
|
|
Array.isArray(savedSizes?.rowSizes) &&
|
|
Array.isArray(savedSizes?.rowColSizes)
|
|
) {
|
|
sizes = {
|
|
rowSizes: savedSizes.rowSizes,
|
|
rowColSizes: savedSizes.rowColSizes,
|
|
};
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
const instanceId = crypto.randomUUID();
|
|
const id = `split-${instanceId}`;
|
|
const splitTab: Tab = {
|
|
id,
|
|
instanceId,
|
|
type: "split-screen",
|
|
label: "Split #1",
|
|
openedAt: Date.now(),
|
|
splitConfig: createSplitConfig(savedMode, restored, sizes),
|
|
};
|
|
setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, restored));
|
|
setActiveTabId(id);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
splitTabsRestoredRef.current = true;
|
|
localStorage.removeItem("termix_splitMode");
|
|
localStorage.removeItem("termix_paneInstanceIds");
|
|
localStorage.removeItem("termix_paneSizes");
|
|
}
|
|
}, [tabsReady, tabs]);
|
|
|
|
// 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) => isPersistentTabType(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]);
|
|
|
|
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
|
|
// keep its last-session snapshot current.
|
|
useEffect(() => {
|
|
if (tabsReady) notifyTabsChanged();
|
|
}, [
|
|
tabs,
|
|
paneTabIds,
|
|
splitMode,
|
|
rowSizes,
|
|
rowColSizes,
|
|
tabsReady,
|
|
railView,
|
|
sidebarOpen,
|
|
sidebarWidth,
|
|
rightRailView,
|
|
rightSidebarWidth,
|
|
]);
|
|
|
|
// ─── Tab management ──────────────────────────────────────────────────────
|
|
|
|
const openTab = useCallback(function openTab(
|
|
host: Host,
|
|
type: TabType,
|
|
restore?: {
|
|
instanceId: string;
|
|
restoredSessionId: string | null;
|
|
savedLabel?: string;
|
|
initialFilePath?: string;
|
|
initialPath?: string;
|
|
serialConfig?: SerialConfig;
|
|
joinSharedSessionId?: string | null;
|
|
joinShareId?: string | null;
|
|
collabRoomId?: string;
|
|
},
|
|
options?: {
|
|
data?: Record<string, unknown>;
|
|
label?: string;
|
|
forceNewTab?: boolean;
|
|
},
|
|
) {
|
|
if (!restore && !options?.forceNewTab) {
|
|
const dataKey = JSON.stringify(options?.data ?? null);
|
|
const existing = tabsRef.current.find(
|
|
(t) =>
|
|
t.type === type &&
|
|
t.host?.id === host.id &&
|
|
JSON.stringify(t.data ?? null) === dataKey,
|
|
);
|
|
if (existing) {
|
|
setActiveTabId(existing.id);
|
|
return existing.id;
|
|
}
|
|
}
|
|
const tabId = `${host.name}-${type}-${Date.now()}`;
|
|
const instanceId =
|
|
restore?.instanceId ??
|
|
(typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`);
|
|
const openedAt = Date.now();
|
|
const ref = isSessionTabType(type) ? createRef() : undefined;
|
|
if (ref) terminalRefs.current.set(tabId, ref);
|
|
|
|
let finalLabel = host.name;
|
|
const savedLabel = restore?.savedLabel;
|
|
const initialFilePath = restore?.initialFilePath;
|
|
const initialPath = restore?.initialPath;
|
|
const serialConfig = restore?.serialConfig;
|
|
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
|
const joinShareId = restore?.joinShareId ?? null;
|
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
|
const isCustomLabel =
|
|
savedLabel != null &&
|
|
savedLabel !== host.name &&
|
|
!/^.+ \(\d+\)$/.test(savedLabel);
|
|
|
|
setTabs((prev) => {
|
|
if (isCustomLabel && savedLabel) {
|
|
finalLabel = savedLabel;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: tabId,
|
|
instanceId,
|
|
type,
|
|
label: finalLabel,
|
|
customLabel: finalLabel,
|
|
host,
|
|
openedAt,
|
|
terminalRef: ref,
|
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
|
joinSharedSessionId,
|
|
joinShareId,
|
|
initialFilePath,
|
|
initialPath,
|
|
serialConfig,
|
|
collabRoomId: restore?.collabRoomId,
|
|
},
|
|
];
|
|
}
|
|
|
|
const same = prev.filter(
|
|
(t) =>
|
|
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
|
);
|
|
finalLabel = options?.label
|
|
? options.label
|
|
: same.length === 0
|
|
? host.name
|
|
: `${host.name} (${same.length + 1})`;
|
|
|
|
// Retrofit the first duplicate's label to "(1)" if needed
|
|
const next =
|
|
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
|
? prev.map((t) =>
|
|
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
|
)
|
|
: prev;
|
|
|
|
return [
|
|
...next,
|
|
{
|
|
id: tabId,
|
|
instanceId,
|
|
type,
|
|
label: finalLabel,
|
|
host,
|
|
openedAt,
|
|
terminalRef: ref,
|
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
|
joinSharedSessionId,
|
|
joinShareId,
|
|
initialFilePath,
|
|
initialPath,
|
|
serialConfig,
|
|
collabRoomId: restore?.collabRoomId,
|
|
data: options?.data,
|
|
},
|
|
];
|
|
});
|
|
setActiveTabId(tabId);
|
|
|
|
if (isPersistentTabType(type)) {
|
|
addOpenTab({
|
|
id: instanceId,
|
|
tabType: type,
|
|
hostId: host ? parseInt(host.id) : null,
|
|
label: finalLabel,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
return tabId;
|
|
}, []);
|
|
|
|
function connectHost(
|
|
host: Host,
|
|
preferredType?: TabType,
|
|
options?: {
|
|
data?: Record<string, unknown>;
|
|
label?: string;
|
|
forceNewTab?: boolean;
|
|
},
|
|
) {
|
|
const type = resolveHostTabType(host, preferredType);
|
|
// --- tmux-monitor --- singleton tab, not a per-host tab
|
|
if (type === "tmux_monitor") {
|
|
openSingletonTab(type, undefined, host);
|
|
return;
|
|
}
|
|
openTab(host, type, undefined, options);
|
|
}
|
|
|
|
const saveQuickConnectHost = useCallback(
|
|
async (tab: Tab, host: Host) => {
|
|
try {
|
|
const savedHost = await createSSHHost(quickConnectHostToPayload(host));
|
|
await patchOpenTab(tab.instanceId, { hostId: savedHost.id });
|
|
await loadHosts();
|
|
toast.success(t("hosts.hostCreated"));
|
|
} catch (error) {
|
|
toast.error(t("hosts.failedToSave"));
|
|
throw error;
|
|
}
|
|
},
|
|
[loadHosts, t],
|
|
);
|
|
|
|
function openSerialTab(config: SerialConfig): string {
|
|
const pseudoHost: Host = {
|
|
id: `serial-${Date.now()}`,
|
|
name: config.path
|
|
? `${config.path} (${config.baudRate})`
|
|
: `Serial (${config.baudRate})`,
|
|
username: "",
|
|
ip: "",
|
|
port: 0,
|
|
folder: "",
|
|
online: false,
|
|
cpu: null,
|
|
ram: null,
|
|
lastAccess: new Date().toISOString(),
|
|
authType: "none",
|
|
enableTerminal: false,
|
|
enableCommandHistory: false,
|
|
enableTunnel: false,
|
|
enableFileManager: false,
|
|
enableDocker: false,
|
|
enableProxmox: false,
|
|
enableProxmoxStats: false,
|
|
enableTmuxMonitor: false,
|
|
enableTerminalToolbar: false,
|
|
enableAiAssistant: false,
|
|
enableSsh: false,
|
|
enableRdp: false,
|
|
enableVnc: false,
|
|
enableTelnet: false,
|
|
sshPort: 22,
|
|
rdpPort: 3389,
|
|
vncPort: 5900,
|
|
telnetPort: 23,
|
|
serverTunnels: [],
|
|
quickActions: [],
|
|
};
|
|
const instanceId =
|
|
typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
return openTab(pseudoHost, "serial", {
|
|
instanceId,
|
|
restoredSessionId: null,
|
|
serialConfig: config,
|
|
});
|
|
}
|
|
|
|
function openLocalTerminalTab(): string {
|
|
const instanceId =
|
|
typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
const id = `local-terminal-${instanceId}`;
|
|
setTabs((current) => {
|
|
const count = current.filter(
|
|
(tab) => tab.type === "local-terminal",
|
|
).length;
|
|
return [
|
|
...current,
|
|
{
|
|
id,
|
|
instanceId,
|
|
type: "local-terminal",
|
|
label:
|
|
count === 0
|
|
? t("nav.localTerminal")
|
|
: `${t("nav.localTerminal")} (${count + 1})`,
|
|
openedAt: Date.now(),
|
|
},
|
|
];
|
|
});
|
|
setActiveTabId(id);
|
|
return id;
|
|
}
|
|
|
|
// Invite awareness: rooms are discovered by polling, so a room that has
|
|
// never been shown to this browser gets one toast with an Open action.
|
|
useEffect(() => {
|
|
const SEEN_KEY = "termix:collab-rooms-seen";
|
|
let cancelled = false;
|
|
const check = async () => {
|
|
try {
|
|
const { listCollabRooms } = await import("@/api/collab-api");
|
|
const { rooms = [] } = await listCollabRooms();
|
|
if (cancelled) return;
|
|
let seen: string[] = [];
|
|
try {
|
|
seen = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "[]");
|
|
} catch {
|
|
seen = [];
|
|
}
|
|
const seenSet = new Set(seen);
|
|
const fresh = rooms.filter((room) => !seenSet.has(room.id));
|
|
if (fresh.length === 0) return;
|
|
localStorage.setItem(
|
|
SEEN_KEY,
|
|
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
|
|
);
|
|
// The first poll after login only records what already exists.
|
|
if (seen.length === 0) return;
|
|
for (const room of fresh) {
|
|
if (room.ownerUserId === userId) continue;
|
|
toast(t("collab.invitedTo", { name: room.name }), {
|
|
action: {
|
|
label: t("collab.openRoom"),
|
|
onClick: () => setRailView("collab"),
|
|
},
|
|
});
|
|
}
|
|
} catch {
|
|
/* next poll */
|
|
}
|
|
};
|
|
void check();
|
|
const timer = setInterval(() => void check(), 60_000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
}, []);
|
|
|
|
const openSingletonTab = useCallback(
|
|
// --- tmux-monitor --- (added optional `host` so tmux_monitor can open
|
|
// with a preselected host; existing callers are unaffected)
|
|
function openSingletonTab(
|
|
type: TabType,
|
|
pendingEvent?: string,
|
|
host?: Host,
|
|
data?: Record<string, unknown>,
|
|
) {
|
|
// Local terminals are never singletons, each one is its own shell.
|
|
if (type === "local-terminal") {
|
|
return openLocalTerminalTab();
|
|
}
|
|
if (type === "host-manager") {
|
|
if (pendingEvent === "host-manager:add-credential") {
|
|
setSidebarOpen(true);
|
|
setRailView("credentials");
|
|
setTimeout(
|
|
() =>
|
|
window.dispatchEvent(
|
|
new CustomEvent("host-manager:add-credential"),
|
|
),
|
|
0,
|
|
);
|
|
} else if (pendingEvent === "host-manager:show-credentials") {
|
|
setSidebarOpen(true);
|
|
setRailView("credentials");
|
|
} else {
|
|
setSidebarOpen(true);
|
|
setRailView("hosts");
|
|
if (pendingEvent) {
|
|
setTimeout(
|
|
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (type === "user-profile" || type === "admin-settings") {
|
|
setSidebarEditing(false);
|
|
setRailView(type as RailView);
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
const id = type;
|
|
const singletonLabels: Partial<Record<TabType, string>> = {
|
|
"host-manager": t("nav.hostManager"),
|
|
tunnel: t("nav.tunnels"),
|
|
sftp: t("nav.sftp"),
|
|
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
|
|
homepage: t("nav.homepage"),
|
|
};
|
|
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
|
// label so the two stay in sync.
|
|
const titleKey = getTabType(type)?.titleKey;
|
|
const label =
|
|
singletonLabels[type] ??
|
|
(titleKey ? t(titleKey) : railItemLabel(type, t));
|
|
setTabs((prev) => {
|
|
const existing = prev.find((t) => t.id === id);
|
|
if (existing) {
|
|
// --- tmux-monitor --- refocusing with a host preselects it
|
|
if (!host && data === undefined) return prev;
|
|
return prev.map((t) =>
|
|
t.id === id
|
|
? {
|
|
...t,
|
|
...(host ? { host } : {}),
|
|
...(data !== undefined ? { data } : {}),
|
|
}
|
|
: t,
|
|
);
|
|
}
|
|
return [
|
|
...prev,
|
|
{
|
|
id,
|
|
instanceId: id,
|
|
type,
|
|
label,
|
|
openedAt: Date.now(),
|
|
...(host ? { host } : {}), // --- tmux-monitor ---
|
|
...(data !== undefined ? { data } : {}),
|
|
},
|
|
];
|
|
});
|
|
setActiveTabId(id);
|
|
if (isPersistentTabType(type)) {
|
|
addOpenTab({
|
|
id,
|
|
tabType: type,
|
|
hostId: null,
|
|
label,
|
|
tabOrder: 0,
|
|
}).catch(() => {});
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const getTabCloseLabel = useCallback((tab: Tab) => {
|
|
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
|
}, []);
|
|
|
|
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
|
if (!isSessionTabType(tab.type)) return false;
|
|
return tab.terminalRef?.current?.isConnected?.() === true;
|
|
}, []);
|
|
|
|
const hasActiveConnection = useCallback(() => {
|
|
return tabsRef.current.some(isActiveConnectionTab);
|
|
}, [isActiveConnectionTab]);
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!hasActiveConnection()) return;
|
|
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
return "";
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
};
|
|
}, [hasActiveConnection]);
|
|
|
|
function doCloseTab(id: string) {
|
|
const tabToClose = tabs.find((t) => t.id === id);
|
|
if (tabToClose?.terminalRef?.current?.disconnect) {
|
|
tabToClose.terminalRef.current.disconnect();
|
|
}
|
|
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
|
|
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
|
}
|
|
|
|
terminalRefs.current.delete(id);
|
|
if (id === activeTabId) {
|
|
const remaining = tabs.filter(
|
|
(tab) => tab.id !== id && !tab.parentSplitTabId,
|
|
);
|
|
setActiveTabId(
|
|
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
|
);
|
|
}
|
|
setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p)));
|
|
setTabs((prev) => {
|
|
const next =
|
|
tabToClose?.type === "split-screen"
|
|
? releaseSplitTabs(prev, id)
|
|
: prev
|
|
.filter((tab) => tab.id !== id)
|
|
.map((tab) =>
|
|
tab.type === "split-screen" && tab.splitConfig
|
|
? {
|
|
...tab,
|
|
splitConfig: {
|
|
...tab.splitConfig,
|
|
paneTabIds: tab.splitConfig.paneTabIds.map((paneId) =>
|
|
paneId === id ? null : paneId,
|
|
),
|
|
},
|
|
}
|
|
: tab,
|
|
);
|
|
if (next.length === 0)
|
|
return [
|
|
{
|
|
id: "dashboard",
|
|
instanceId: "dashboard",
|
|
type: "dashboard",
|
|
label: t("nav.dashboard"),
|
|
openedAt: Date.now(),
|
|
},
|
|
];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function refreshTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
const handle = tab?.terminalRef?.current;
|
|
if (!handle) return;
|
|
// Session tabs expose one or the other on their handle.
|
|
if (handle.reconnect) handle.reconnect();
|
|
else handle.refresh?.();
|
|
}
|
|
|
|
function openShareForTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
if (!tab) return;
|
|
const ref = tab.terminalRef?.current;
|
|
if (ref?.canShare?.()) {
|
|
ref.openShareModal?.();
|
|
} else {
|
|
toast.error(t("sessionSharing.notReadyToShare"));
|
|
}
|
|
}
|
|
|
|
function closeTab(id: string) {
|
|
const tab = tabs.find((t) => t.id === id);
|
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
|
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
|
|
const closeLabel = getTabCloseLabel(tab);
|
|
const toastId = `close-tab-${id}`;
|
|
toast(
|
|
t("nav.confirmCloseHost", {
|
|
host: closeLabel,
|
|
defaultValue: `Close ${closeLabel}?`,
|
|
}),
|
|
{
|
|
id: toastId,
|
|
duration: 8000,
|
|
action: {
|
|
label: t("nav.close"),
|
|
onClick: () => {
|
|
toast.dismiss(toastId);
|
|
doCloseTab(id);
|
|
},
|
|
},
|
|
cancel: {
|
|
label: t("nav.cancel"),
|
|
onClick: () => toast.dismiss(toastId),
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (tab && isSessionTabType(tab.type) && confirmEnabled) {
|
|
toast.dismiss(`close-tab-${id}`);
|
|
}
|
|
|
|
doCloseTab(id);
|
|
}
|
|
|
|
closeActiveTabRef.current = () => {
|
|
const id = activeTabIdRef.current;
|
|
if (id !== "dashboard") closeTab(id);
|
|
};
|
|
|
|
function renameTab(tabId: string, newLabel: string) {
|
|
setTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
|
),
|
|
);
|
|
const tab = tabs.find((t) => t.id === tabId);
|
|
if (tab?.instanceId && tab.type !== "split-screen") {
|
|
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function splitTabQuick(tabId: string, mode: SplitMode) {
|
|
if (mode === "none") return;
|
|
const count = PANE_COUNTS[mode];
|
|
const paneIds: (string | null)[] = Array(6).fill(null);
|
|
paneIds[0] = tabId;
|
|
let slot = 1;
|
|
for (const tab of tabs) {
|
|
if (slot >= count) break;
|
|
if (
|
|
tab.id !== tabId &&
|
|
tab.type !== "dashboard" &&
|
|
tab.type !== "split-screen" &&
|
|
!tab.parentSplitTabId
|
|
) {
|
|
paneIds[slot++] = tab.id;
|
|
}
|
|
}
|
|
const splitNumber =
|
|
tabs.filter((tab) => tab.type === "split-screen").length + 1;
|
|
const instanceId = crypto.randomUUID();
|
|
const id = `split-${instanceId}`;
|
|
const sizes = defaultSizes(mode);
|
|
const splitTab: Tab = {
|
|
id,
|
|
instanceId,
|
|
type: "split-screen",
|
|
label: `Split #${splitNumber}`,
|
|
openedAt: Date.now(),
|
|
splitConfig: createSplitConfig(mode, paneIds, sizes),
|
|
};
|
|
setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, paneIds));
|
|
setActiveTabId(id);
|
|
setSplitMode(mode);
|
|
setPaneTabIds(paneIds);
|
|
setRowSizes(sizes.rowSizes);
|
|
setRowColSizes(sizes.rowColSizes);
|
|
}
|
|
|
|
function addTabToSplit(tabId: string) {
|
|
if (splitMode === "none") {
|
|
splitTabQuick(tabId, "2-way");
|
|
return;
|
|
}
|
|
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 selectSplitMode(mode: SplitMode) {
|
|
const active = tabs.find((tab) => tab.id === activeTabId);
|
|
if (mode === "none") {
|
|
if (active?.type === "split-screen") doCloseTab(active.id);
|
|
return;
|
|
}
|
|
if (active?.type === "split-screen") {
|
|
changeSplitMode(mode);
|
|
return;
|
|
}
|
|
if (active && active.type !== "dashboard") {
|
|
splitTabQuick(active.id, mode);
|
|
return;
|
|
}
|
|
const firstSession = tabs.find(
|
|
(tab) =>
|
|
tab.type !== "dashboard" &&
|
|
tab.type !== "split-screen" &&
|
|
!tab.parentSplitTabId,
|
|
);
|
|
if (firstSession) splitTabQuick(firstSession.id, mode);
|
|
}
|
|
|
|
function assignPane(paneIndex: number, tabId: string) {
|
|
setPaneTabIds((prev) => {
|
|
const next = prev.map((p) => (p === tabId ? null : p));
|
|
next[paneIndex] = tabId || null;
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// ─── Rail / sidebar ──────────────────────────────────────────────────────
|
|
|
|
// Moving a panel to the right dock rather than copying it: two live copies of
|
|
// the same panel would fight over the shared editing state.
|
|
function openInRightDock(view: RailView) {
|
|
setRightRailView(view);
|
|
if (railView === view) setSidebarOpen(false);
|
|
}
|
|
|
|
// Tab bar toggle: reopens whatever was last in the dock, so it behaves like a
|
|
// show/hide rather than losing the user's choice each time.
|
|
function toggleRightDock() {
|
|
if (rightRailView) {
|
|
lastRightRailViewRef.current = rightRailView;
|
|
setRightRailView(null);
|
|
return;
|
|
}
|
|
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
|
|
if (fallback) setRightRailView(fallback as RailView);
|
|
}
|
|
|
|
function handleRailClick(view: RailView) {
|
|
if (view === "sftp") {
|
|
openSingletonTab("sftp");
|
|
if (isMobile) setSidebarOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (railView === view && sidebarOpen) {
|
|
setSidebarOpen(false);
|
|
} else {
|
|
// A panel lives in one dock at a time, so the left dock reclaims it.
|
|
if (rightRailView === view) setRightRailView(null);
|
|
if (view !== railView) setSidebarEditing(false);
|
|
if (view !== railView) setSettingsFullscreen(false);
|
|
setRailView(view);
|
|
setSidebarOpen(true);
|
|
}
|
|
}
|
|
|
|
function editHostInManager(host: Host) {
|
|
setSidebarOpen(true);
|
|
setRailView("hosts");
|
|
setTimeout(() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("host-manager:edit-host", { detail: host.id }),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
const onSidebarMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setSidebarDragging(true);
|
|
const startX = e.clientX;
|
|
const startW = sidebarWidth;
|
|
function onMove(ev: MouseEvent) {
|
|
setSidebarWidth(
|
|
Math.max(160, Math.min(480, startW + ev.clientX - startX)),
|
|
);
|
|
}
|
|
function onUp() {
|
|
setSidebarDragging(false);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
}
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[sidebarWidth],
|
|
);
|
|
|
|
// Same drag, mirrored: the right dock grows as the pointer moves left.
|
|
const onRightSidebarMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setRightSidebarDragging(true);
|
|
const startX = e.clientX;
|
|
const startW = rightSidebarWidth;
|
|
function onMove(ev: MouseEvent) {
|
|
setRightSidebarWidth(
|
|
Math.max(160, Math.min(480, startW - (ev.clientX - startX))),
|
|
);
|
|
}
|
|
function onUp() {
|
|
setRightSidebarDragging(false);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
}
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[rightSidebarWidth],
|
|
);
|
|
|
|
// Resize all terminals in panes + active terminal when split mode or sidebar changes
|
|
const resizeAllTerminals = useCallback(() => {
|
|
const id = requestAnimationFrame(() => {
|
|
tabs.forEach((tab) => {
|
|
if (!tab.terminalRef) return;
|
|
const ref = tab.terminalRef.current;
|
|
ref?.fit?.();
|
|
ref?.notifyResize?.();
|
|
});
|
|
});
|
|
return id;
|
|
}, [tabs]);
|
|
|
|
useEffect(() => {
|
|
const id = resizeAllTerminals();
|
|
return () => cancelAnimationFrame(id);
|
|
}, [splitMode, sidebarWidth, sidebarOpen, rightSidebarWidth, rightRailView]);
|
|
|
|
const isSplit =
|
|
splitMode !== "none" &&
|
|
tabs.some((tab) => tab.id === activeTabId && tab.type === "split-screen");
|
|
|
|
// Move each tab's stable DOM node to the right container (pane or normal-view).
|
|
// This is vanilla DOM so React's portal target never changes — changing the portal
|
|
// target causes a remount which is exactly what we're trying to avoid.
|
|
// useLayoutEffect (not useEffect) so visibility/display are corrected before
|
|
// the browser paints — otherwise the previous tab's node can flash on screen
|
|
// for a frame while still visible.
|
|
useLayoutEffect(() => {
|
|
const normalView = normalViewRef.current;
|
|
if (!normalView) return;
|
|
|
|
const tabIds = new Set(tabs.map((t) => t.id));
|
|
|
|
// Remove nodes for closed tabs
|
|
for (const [id, node] of tabNodesRef.current) {
|
|
if (!tabIds.has(id)) {
|
|
node.remove();
|
|
tabNodesRef.current.delete(id);
|
|
}
|
|
}
|
|
|
|
for (const tab of tabs) {
|
|
const isTerminal =
|
|
tab.type === "terminal" || tab.type === "local-terminal";
|
|
const node = getTabNode(tab.id, isTerminal);
|
|
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
|
const inPane = paneIdx !== -1;
|
|
const paneEl = inPane ? paneContentEls[paneIdx] : null;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
|
|
if (inPane && paneEl) {
|
|
if (node.parentElement !== paneEl) paneEl.appendChild(node);
|
|
node.style.visibility = "visible";
|
|
node.style.pointerEvents = "auto";
|
|
node.style.display = "";
|
|
node.style.zIndex = "";
|
|
node.style.contentVisibility = "";
|
|
} else {
|
|
if (node.parentElement !== normalView) normalView.appendChild(node);
|
|
if (isTerminal) {
|
|
node.style.display = "";
|
|
node.style.visibility = activeInline ? "visible" : "hidden";
|
|
node.style.pointerEvents = activeInline ? "auto" : "none";
|
|
node.style.zIndex = activeInline ? "1" : "0";
|
|
// xterm renders to a <canvas>; visibility:hidden alone can still let
|
|
// a stale composited frame flash through for a tick when switching
|
|
// to/from a non-terminal tab. content-visibility:hidden fully skips
|
|
// painting the subtree while keeping its layout box intact, so
|
|
// fitAddon.fit() still sees correct dimensions once it's shown again.
|
|
node.style.contentVisibility = activeInline ? "" : "hidden";
|
|
} else {
|
|
// Plays a quick opacity fade-in on the tab that just became active.
|
|
// Only play it on an actual switch into this tab -- gating on the
|
|
// class alone replayed the animation on every unrelated re-render
|
|
// that happened while the tab was still active (any render after
|
|
// animationend had stripped the class), which looked like the
|
|
// panel kept growing for up to a second after switching.
|
|
if (activeInline && lastAnimatedTabIdRef.current !== tab.id) {
|
|
lastAnimatedTabIdRef.current = tab.id;
|
|
node.classList.remove("motion-workspace-enter");
|
|
// Force a reflow so re-adding the class restarts the animation
|
|
// instead of no-oping because it was already removed this tick.
|
|
void node.offsetWidth;
|
|
node.classList.add("motion-workspace-enter");
|
|
node.addEventListener(
|
|
"animationend",
|
|
() => node.classList.remove("motion-workspace-enter"),
|
|
{ once: true },
|
|
);
|
|
} else if (!activeInline) {
|
|
node.classList.remove("motion-workspace-enter");
|
|
if (lastAnimatedTabIdRef.current === tab.id) {
|
|
lastAnimatedTabIdRef.current = null;
|
|
}
|
|
}
|
|
node.style.visibility = "";
|
|
node.style.pointerEvents = "";
|
|
node.style.zIndex = activeInline ? "2" : "";
|
|
node.style.display = activeInline ? "" : "none";
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const terminalTabs = tabs.filter((t) => t.type === "terminal");
|
|
const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId);
|
|
|
|
function reorderTopLevelTabs(reordered: Tab[]) {
|
|
setTabs((prev) => [
|
|
...reordered,
|
|
...prev.filter((tab) => tab.parentSplitTabId),
|
|
]);
|
|
}
|
|
|
|
// What history/snippets/ssh-tools should act on. Falls back to the remembered
|
|
// terminal when the active tab isn't one, and drops it once it's closed.
|
|
const targetTerminalTabId = terminalTabs.some((t) => t.id === activeTabId)
|
|
? activeTabId
|
|
: terminalTabs.some((t) => t.id === lastTerminalTabId)
|
|
? lastTerminalTabId
|
|
: "";
|
|
|
|
/**
|
|
* Sidebar panel content, shared between the desktop sidebar, the mobile
|
|
* sheet and the right dock. Takes the view rather than reading railView so
|
|
* both docks can render from the same code.
|
|
*
|
|
* `owned` marks the dock responsible for the panels that stay mounted while
|
|
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
|
|
* two live instances fight over the shared editing state.
|
|
*/
|
|
// The param deliberately shadows the outer railView so the body reads the
|
|
// same whichever dock is rendering.
|
|
const newInstanceId = () =>
|
|
typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
/**
|
|
* What plugins may ask of the shell. Rebuilt every render so it always
|
|
* closes over current state, and published to the plugin runtime.
|
|
*/
|
|
const shellCallbacks: TabShellCallbacks = {
|
|
openTab: (host, type, options) => {
|
|
if (!host || getTabType(type)?.singleton) {
|
|
openSingletonTab(type, undefined, host ?? undefined, options?.data);
|
|
return;
|
|
}
|
|
openTab(host, type, undefined, options);
|
|
},
|
|
openSingletonTab: (type, options) =>
|
|
openSingletonTab(type, undefined, undefined, options?.data),
|
|
closeTab: (tabId) => closeTab(tabId),
|
|
renameTab: (tabId, label) => renameTab(tabId, label),
|
|
openFileInEditor: (host, filePath) =>
|
|
openTab(host, "files", {
|
|
instanceId: newInstanceId(),
|
|
restoredSessionId: null,
|
|
initialFilePath: filePath,
|
|
}),
|
|
openFileManager: (host, path) =>
|
|
openTab(host, "files", {
|
|
instanceId: newInstanceId(),
|
|
restoredSessionId: null,
|
|
initialPath: path,
|
|
}),
|
|
openTerminalTab: (host, path) =>
|
|
openTab(host, "terminal", {
|
|
instanceId: newInstanceId(),
|
|
restoredSessionId: null,
|
|
initialFilePath: path,
|
|
}),
|
|
openRailView: (id) => {
|
|
setRailView(id as RailView);
|
|
setSidebarOpen(true);
|
|
},
|
|
closeRailView: (id) => {
|
|
setRailView((prev) => (prev === id ? "hosts" : prev));
|
|
setRightRailView((prev) => (prev === id ? null : prev));
|
|
},
|
|
saveQuickConnect: saveQuickConnectHost,
|
|
};
|
|
|
|
// Panels close the sidebar on mobile after opening something, the same as
|
|
// the hosts panel does.
|
|
const panelShell: TabShellCallbacks = {
|
|
...shellCallbacks,
|
|
openTab: (...args) => {
|
|
shellCallbacks.openTab(...args);
|
|
if (isMobile) setSidebarOpen(false);
|
|
},
|
|
};
|
|
|
|
useEffect(() => {
|
|
setShellCallbacks(shellCallbacks);
|
|
setShellLayoutProvider({
|
|
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
|
|
applyLayout: (layout, options) =>
|
|
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
|
|
});
|
|
});
|
|
|
|
useEffect(() => {
|
|
setShellHosts(allHosts, hostsLoaded);
|
|
}, [allHosts, hostsLoaded]);
|
|
|
|
const renderSidebarPanels = (railView: RailView, owned = true) => (
|
|
<Suspense fallback={<SidebarPanelFallback />}>
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{owned && (
|
|
<>
|
|
<div
|
|
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
|
>
|
|
<HostsPanel
|
|
onOpenTab={(host, type, options) => {
|
|
connectHost(host, type, options);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onEditHost={editHostInManager}
|
|
hostTree={realHostTree ?? undefined}
|
|
loading={hostsLoading}
|
|
onEditingChange={setSidebarEditing}
|
|
active={railView === "hosts"}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
|
|
>
|
|
<CredentialsPanel
|
|
onEditingChange={setSidebarEditing}
|
|
active={railView === "credentials"}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{railView === "port-forwarding" && (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<PortForwardingPanel />
|
|
</div>
|
|
)}
|
|
|
|
{railView === "termix-id" && (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<TermixIdPanel />
|
|
</div>
|
|
)}
|
|
|
|
{railView === "serial" && (
|
|
<SerialPanel
|
|
onConnect={(config) => {
|
|
openSerialTab(config);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{railView === "quick-connect" && (
|
|
<QuickConnectPanel
|
|
onConnect={(host, type) => {
|
|
openTab(host, type);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{railView === "ssh-tools" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SshToolsPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={targetTerminalTabId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "snippets" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SnippetsPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={targetTerminalTabId}
|
|
storageMode={
|
|
userPrefs.storageMode === "cloud" ? "cloud" : "local"
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "macros" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<MacrosPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={targetTerminalTabId}
|
|
storageMode={
|
|
userPrefs.storageMode === "cloud" ? "cloud" : "local"
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "history" && (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
<HistoryPanel
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={targetTerminalTabId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "split-screen" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<SplitScreenPanel
|
|
tabs={tabs.filter(
|
|
(tab) =>
|
|
tab.type !== "split-screen" &&
|
|
(!tab.parentSplitTabId ||
|
|
tab.parentSplitTabId === activeTabId),
|
|
)}
|
|
splitMode={splitMode}
|
|
setSplitMode={selectSplitMode}
|
|
paneTabIds={paneTabIds}
|
|
setPaneTabIds={setPaneTabIds}
|
|
onAssignPane={assignPane}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{registeredPanels.map((panel) => {
|
|
const shown = railView === panel.id;
|
|
// A kept-mounted panel lives in the owning dock only, so two live
|
|
// copies never fight over the same state.
|
|
if (panel.keepMounted ? !owned : !shown) return null;
|
|
const Panel = panel.component;
|
|
return (
|
|
<div
|
|
key={panel.id}
|
|
className={`flex flex-col flex-1 min-h-0 overflow-y-auto ${shown ? "" : "hidden"}`}
|
|
>
|
|
<Panel
|
|
active={shown}
|
|
shell={panelShell}
|
|
setEditing={setSidebarEditing}
|
|
activeTabType={
|
|
tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined
|
|
}
|
|
placement={owned ? "left" : "right"}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{!isCoreRailView(railView) && !getPanel(railView) && (
|
|
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
|
|
)}
|
|
|
|
{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,
|
|
savedLabel: record.label,
|
|
});
|
|
} else {
|
|
openSingletonTab(record.tabType as TabType);
|
|
}
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
onForgetBackground={(recordId) => {
|
|
setBackgroundTabRecords((prev) =>
|
|
prev.filter((r) => r.id !== recordId),
|
|
);
|
|
}}
|
|
onRenameTab={renameTab}
|
|
onReorderTabs={setTabs}
|
|
onJoinSharedSession={(session) => {
|
|
if (!session.shareId) return;
|
|
const existingHost = allHosts.find(
|
|
(h) => h.id === String(session.hostId),
|
|
);
|
|
const host: Host = existingHost ?? {
|
|
id: String(session.hostId),
|
|
name: session.hostName,
|
|
username: "",
|
|
ip: "",
|
|
port: 0,
|
|
folder: "",
|
|
online: false,
|
|
cpu: null,
|
|
ram: null,
|
|
lastAccess: new Date().toISOString(),
|
|
authType: "none",
|
|
enableTerminal: false,
|
|
enableCommandHistory: false,
|
|
enableTunnel: false,
|
|
enableFileManager: false,
|
|
enableDocker: false,
|
|
enableProxmox: false,
|
|
enableProxmoxStats: false,
|
|
enableTmuxMonitor: false,
|
|
enableTerminalToolbar: false,
|
|
enableAiAssistant: false,
|
|
enableSsh: false,
|
|
enableRdp: false,
|
|
enableVnc: false,
|
|
enableTelnet: false,
|
|
sshPort: 22,
|
|
rdpPort: 3389,
|
|
vncPort: 5900,
|
|
telnetPort: 23,
|
|
serverTunnels: [],
|
|
quickActions: [],
|
|
};
|
|
const instanceId =
|
|
typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
openTab(host, "terminal", {
|
|
instanceId,
|
|
restoredSessionId: null,
|
|
joinSharedSessionId: session.sessionId,
|
|
joinShareId: session.shareId,
|
|
savedLabel: t("connections.sharedSessionLabel", {
|
|
hostName: session.hostName,
|
|
}),
|
|
});
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "collab" && (
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
<CollabPanel
|
|
onOpenRoom={(room) => {
|
|
const roomHost: Host = {
|
|
id: `collab-${room.id}`,
|
|
name: room.name,
|
|
username: "",
|
|
ip: "",
|
|
port: 0,
|
|
folder: "",
|
|
online: false,
|
|
cpu: null,
|
|
ram: null,
|
|
lastAccess: new Date().toISOString(),
|
|
authType: "none",
|
|
enableTerminal: false,
|
|
enableCommandHistory: false,
|
|
enableTunnel: false,
|
|
enableFileManager: false,
|
|
enableDocker: false,
|
|
enableProxmox: false,
|
|
enableProxmoxStats: false,
|
|
enableTmuxMonitor: false,
|
|
enableTerminalToolbar: false,
|
|
enableAiAssistant: false,
|
|
enableSsh: false,
|
|
enableRdp: false,
|
|
enableVnc: false,
|
|
enableTelnet: false,
|
|
sshPort: 22,
|
|
rdpPort: 3389,
|
|
vncPort: 5900,
|
|
telnetPort: 23,
|
|
serverTunnels: [],
|
|
quickActions: [],
|
|
};
|
|
openTab(roomHost, "collab", {
|
|
instanceId:
|
|
typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
|
restoredSessionId: null,
|
|
savedLabel: room.name,
|
|
collabRoomId: room.id,
|
|
});
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{railView === "session-logs" && (
|
|
<div className="relative flex-1 min-h-0 flex flex-col">
|
|
<SessionLogsPanel />
|
|
</div>
|
|
)}
|
|
|
|
{(railView === "user-profile" ||
|
|
(railView === "admin-settings" && showAdminUI)) && (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<SettingsScreen
|
|
initialSection={
|
|
railView === "admin-settings" ? "admin" : "profile"
|
|
}
|
|
isAdmin={showAdminUI}
|
|
username={username}
|
|
onLogout={onLogout}
|
|
userPrefs={userPrefs}
|
|
onPrefsChange={(updates) =>
|
|
setUserPrefs((current) => ({ ...current, ...updates }))
|
|
}
|
|
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
|
|
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
|
|
onRemoteSyncReconnectHandled={() =>
|
|
setRemoteSyncReconnectRequested(false)
|
|
}
|
|
onEditingChange={setSidebarEditing}
|
|
onOpenHostTab={(host) => {
|
|
connectHost(host);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
|
|
const sidebarPanelContent = renderSidebarPanels(railView);
|
|
|
|
// Sidebar header — shared
|
|
const sidebarHeader = (
|
|
<div className="flex flex-row items-center border-b border-border h-12.5 shrink-0">
|
|
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
|
|
{sidebarTitle(railView)}
|
|
</span>
|
|
{!isMobile && promotableIds().includes(railView) && (
|
|
<>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
|
title={t("nav.openAsTab")}
|
|
aria-label={t("nav.openAsTab")}
|
|
onClick={() => openSingletonTab(railView as TabType)}
|
|
>
|
|
<SquareArrowOutUpRight className="size-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{!isMobile && rightDockableIds().includes(railView) && (
|
|
<>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
|
title={t("nav.openInRightDock")}
|
|
aria-label={t("nav.openInRightDock")}
|
|
onClick={() => openInRightDock(railView)}
|
|
>
|
|
<PanelRight className="size-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{!isMobile && (
|
|
<>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 border-y-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
|
title="Reset width"
|
|
onClick={() => setSidebarWidth(291)}
|
|
>
|
|
<RotateCcw className="size-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isSettingsView && (
|
|
<>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
|
title={
|
|
settingsFullscreen
|
|
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
|
|
: t("newUi.sidebar.userProfile.openFullscreenSettings")
|
|
}
|
|
aria-label={
|
|
settingsFullscreen
|
|
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
|
|
: t("newUi.sidebar.userProfile.openFullscreenSettings")
|
|
}
|
|
onClick={() => setSettingsFullscreen((value) => !value)}
|
|
>
|
|
{settingsFullscreen ? (
|
|
<Minimize2 className="size-4" />
|
|
) : (
|
|
<Maximize2 className="size-4" />
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
|
title="Collapse sidebar"
|
|
onClick={() => {
|
|
setSettingsFullscreen(false);
|
|
setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
const sidebarHint = !isMobile && (
|
|
<MultiPanelHint
|
|
canPromote={promotableIds().includes(railView)}
|
|
canRightDock={rightDockableIds().includes(railView)}
|
|
onOpenAsTab={() => openSingletonTab(railView as TabType)}
|
|
onOpenInRightDock={() => openInRightDock(railView)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<ServerStatusProvider isAuthenticated={!!username}>
|
|
<div
|
|
className="flex flex-col w-screen bg-background"
|
|
style={{ height: "100dvh" }}
|
|
>
|
|
{isElectron() && (
|
|
<>
|
|
<RemoteSyncBanner
|
|
onReconnect={() => {
|
|
setRailView("user-profile");
|
|
if (!sidebarOpen) setSidebarOpen(true);
|
|
setRemoteSyncReconnectRequested(true);
|
|
}}
|
|
/>
|
|
<MigrationNoticeDialog
|
|
onOpenRemoteSync={(url) => {
|
|
setRemoteSyncInitialServerUrl(url);
|
|
setRailView("user-profile");
|
|
if (!sidebarOpen) setSidebarOpen(true);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
|
{!settingsFullscreen && (
|
|
<AppRail
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
splitMode={splitMode}
|
|
username={username}
|
|
isAdmin={showAdminUI}
|
|
onRailClick={handleRailClick}
|
|
onOpenTab={openSingletonTab}
|
|
onOpenInRightDock={openInRightDock}
|
|
onLogout={onLogout}
|
|
/>
|
|
)}
|
|
|
|
{/* Desktop: inline resizable sidebar */}
|
|
{!isMobile && (
|
|
<div
|
|
className={`${settingsFullscreen ? "fixed inset-0 z-50" : "relative"} flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
|
style={{
|
|
width: settingsFullscreen
|
|
? "100vw"
|
|
: sidebarOpen
|
|
? sidebarEditing
|
|
? 560
|
|
: sidebarWidth
|
|
: 0,
|
|
transition: sidebarDragging ? "none" : "width 0.2s",
|
|
}}
|
|
>
|
|
{sidebarHeader}
|
|
{sidebarHint}
|
|
{sidebarPanelContent}
|
|
|
|
{sidebarOpen && !sidebarEditing && !settingsFullscreen && (
|
|
<div
|
|
onMouseDown={onSidebarMouseDown}
|
|
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: sidebar as overlay sheet */}
|
|
{isMobile && (
|
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
<SheetContent
|
|
side="left"
|
|
showCloseButton={false}
|
|
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${settingsFullscreen ? "w-screen" : "w-[min(85vw,360px)]"}`}
|
|
style={{ height: "100dvh" }}
|
|
>
|
|
{sidebarHeader}
|
|
{sidebarPanelContent}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)}
|
|
|
|
{/* Main content area */}
|
|
<div
|
|
inert={settingsFullscreen ? true : undefined}
|
|
aria-hidden={settingsFullscreen || undefined}
|
|
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
|
>
|
|
{!isMobile && !sidebarOpen && (
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
title="Open Sidebar"
|
|
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
|
>
|
|
<ChevronRight className="size-3.5" />
|
|
</button>
|
|
)}
|
|
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
|
<TabBar
|
|
tabs={topLevelTabs}
|
|
activeTabId={activeTabId}
|
|
splitMode={splitMode}
|
|
paneTabIds={paneTabIds}
|
|
focusedPaneIndex={focusedPaneIndex}
|
|
onSetActiveTab={setActiveTabId}
|
|
onCloseTab={closeTab}
|
|
onRefreshTab={refreshTab}
|
|
onReorderTabs={reorderTopLevelTabs}
|
|
onSplitTab={splitTabQuick}
|
|
onAddToSplit={addTabToSplit}
|
|
onRemoveFromSplit={removeTabFromSplit}
|
|
onRenameTab={renameTab}
|
|
onOpenFileManager={(tabId) => {
|
|
const targetTab = tabs.find((t) => t.id === tabId);
|
|
if (targetTab?.host) openTab(targetTab.host, "files");
|
|
}}
|
|
onOpenShare={openShareForTab}
|
|
isAppFullscreen={isAppFullscreen}
|
|
onToggleAppFullscreen={toggleAppFullscreen}
|
|
rightDockOpen={rightRailView !== null}
|
|
onToggleRightDock={isMobile ? undefined : toggleRightDock}
|
|
showTabNumbers={showTabNumbers}
|
|
/>
|
|
<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 */}
|
|
{!isMobile && (
|
|
<div
|
|
className="motion-workspace-layout absolute inset-0"
|
|
style={{
|
|
display: isSplit ? "flex" : "none",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<SplitView
|
|
tabs={tabs}
|
|
paneTabIds={paneTabIds}
|
|
splitMode={splitMode}
|
|
rowSizes={rowSizes}
|
|
rowColSizes={rowColSizes}
|
|
onRowSizesChange={setRowSizes}
|
|
onRowColSizesChange={setRowColSizes}
|
|
onReset={() => changeSplitMode(splitMode)}
|
|
focusedPaneIndex={focusedPaneIndex}
|
|
onTerminalResize={resizeAllTerminals}
|
|
onPaneContentRef={onPaneContentRef}
|
|
onPaneClick={setFocusedPaneIndex}
|
|
onAssignPane={assignPane}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
|
by the DOM-placement effect above. React portals each tab's content
|
|
into its stable per-tab node so the component is never remounted.
|
|
When split is active, shown on top only if the active tab is not in a pane. */}
|
|
<div
|
|
ref={normalViewRef}
|
|
className="absolute inset-0"
|
|
style={{
|
|
display: isSplit && !isMobile ? "none" : undefined,
|
|
}}
|
|
>
|
|
{tabsByPortalOrder.map((tab) => {
|
|
const tabNode = getTabNode(
|
|
tab.id,
|
|
tab.type === "terminal" || tab.type === "local-terminal",
|
|
);
|
|
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
|
const inPane = paneIdx !== -1;
|
|
const activeInline = !inPane && tab.id === activeTabId;
|
|
const isFocusedPane = inPane
|
|
? paneIdx === (focusedPaneIndex ?? 0)
|
|
: activeInline;
|
|
return createPortal(
|
|
renderTabContent(tab, {
|
|
shell: shellCallbacks,
|
|
isVisible: inPane || activeInline,
|
|
isFocusedPane,
|
|
panelProps: {
|
|
terminalTabs,
|
|
targetTerminalTabId,
|
|
storageMode:
|
|
userPrefs.storageMode === "cloud"
|
|
? "cloud"
|
|
: "local",
|
|
},
|
|
}),
|
|
tabNode,
|
|
tab.id,
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom nav bar — mobile only */}
|
|
<MobileBottomBar
|
|
railView={railView}
|
|
sidebarOpen={sidebarOpen}
|
|
splitMode={splitMode}
|
|
onRailClick={handleRailClick}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right dock — desktop only, holds a second reference panel */}
|
|
{!isMobile && rightRailView && !settingsFullscreen && (
|
|
<div
|
|
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden border-l transition-colors ${rightSidebarDragging ? "border-accent-brand/60" : "border-border"}`}
|
|
style={{
|
|
width: rightSidebarWidth,
|
|
transition: rightSidebarDragging ? "none" : "width 0.2s",
|
|
}}
|
|
>
|
|
<div className="flex flex-row items-center border-b border-border h-12.5 shrink-0">
|
|
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
|
|
{sidebarTitle(rightRailView)}
|
|
</span>
|
|
<Separator orientation="vertical" />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
|
title={t("nav.closeRightDock")}
|
|
aria-label={t("nav.closeRightDock")}
|
|
onClick={() => setRightRailView(null)}
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{renderSidebarPanels(rightRailView, false)}
|
|
|
|
<div
|
|
onMouseDown={onRightSidebarMouseDown}
|
|
className={`absolute left-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${rightSidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{commandPaletteOpen && (
|
|
<Suspense fallback={null}>
|
|
<CommandPalette
|
|
isOpen={commandPaletteOpen}
|
|
setIsOpen={setCommandPaletteOpen}
|
|
hosts={allHosts}
|
|
terminalTabs={terminalTabs}
|
|
activeTabId={activeTabId}
|
|
onOpenPanel={(view) => handleRailClick(view as RailView)}
|
|
onOpenTab={(type, label, pendingEvent) => {
|
|
if (
|
|
[
|
|
"dashboard",
|
|
"host-manager",
|
|
"user-profile",
|
|
"admin-settings",
|
|
].includes(type)
|
|
) {
|
|
openSingletonTab(type, pendingEvent);
|
|
} else if (type === "local-terminal") {
|
|
openLocalTerminalTab();
|
|
} else if (type === "tmux_monitor") {
|
|
// --- tmux-monitor --- singleton tab, optionally preselecting a host
|
|
openSingletonTab(
|
|
type,
|
|
undefined,
|
|
label ? allHosts.find((h) => h.name === label) : undefined,
|
|
);
|
|
} else if (label) {
|
|
const host = allHosts.find((h) => h.name === label);
|
|
if (host) openTab(host, type);
|
|
}
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
<TransferMonitor />
|
|
<Suspense fallback={null}>
|
|
<AlertManager userId={userId} loggedIn={!!username} />
|
|
</Suspense>
|
|
<OnboardingDialog
|
|
open={showOnboarding}
|
|
context={{}}
|
|
onClose={() => setShowOnboarding(false)}
|
|
/>
|
|
<DonationReminderModal
|
|
open={showDonationModal && !showOnboarding}
|
|
onDismiss={handleDismissDonationModal}
|
|
/>
|
|
</ServerStatusProvider>
|
|
);
|
|
}
|