307 lines
8.0 KiB
TypeScript
307 lines
8.0 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useRef,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TabContextTab } from "../../../types/index.js";
|
|
|
|
export type Tab = TabContextTab;
|
|
|
|
interface TabContextType {
|
|
tabs: Tab[];
|
|
currentTab: number | null;
|
|
allSplitScreenTab: number[];
|
|
addTab: (tab: Omit<Tab, "id">) => number;
|
|
removeTab: (tabId: number) => void;
|
|
setCurrentTab: (tabId: number) => void;
|
|
setSplitScreenTab: (tabId: number) => void;
|
|
getTab: (tabId: number) => Tab | undefined;
|
|
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
|
updateHostConfig: (
|
|
hostId: number,
|
|
newHostConfig: {
|
|
id: number;
|
|
name?: string;
|
|
username: string;
|
|
ip: string;
|
|
port: number;
|
|
},
|
|
) => void;
|
|
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
|
}
|
|
|
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
|
|
|
export function useTabs() {
|
|
const context = useContext(TabContext);
|
|
if (context === undefined) {
|
|
throw new Error("useTabs must be used within a TabProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
interface TabProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function TabProvider({ children }: TabProviderProps) {
|
|
const { t } = useTranslation();
|
|
const [tabs, setTabs] = useState<Tab[]>(() => [
|
|
{ id: 1, type: "home", title: "Home" },
|
|
]);
|
|
const [currentTab, setCurrentTab] = useState<number>(1);
|
|
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
|
const nextTabId = useRef(2);
|
|
|
|
// Update home tab title when translation changes
|
|
React.useEffect(() => {
|
|
setTabs((prev) =>
|
|
prev.map((tab) =>
|
|
tab.id === 1 && tab.type === "home"
|
|
? { ...tab, title: t("nav.home") }
|
|
: tab,
|
|
),
|
|
);
|
|
}, [t]);
|
|
|
|
function computeUniqueTitle(
|
|
tabType: Tab["type"],
|
|
desiredTitle: string | undefined,
|
|
): string {
|
|
const defaultTitle =
|
|
tabType === "server"
|
|
? t("nav.serverStats")
|
|
: tabType === "file_manager"
|
|
? t("nav.fileManager")
|
|
: tabType === "tunnel"
|
|
? t("nav.tunnels")
|
|
: tabType === "docker"
|
|
? t("nav.docker")
|
|
: t("nav.terminal");
|
|
const baseTitle = (desiredTitle || defaultTitle).trim();
|
|
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
|
const root = match ? match[1] : baseTitle;
|
|
|
|
const usedNumbers = new Set<number>();
|
|
let rootUsed = false;
|
|
tabs.forEach((t) => {
|
|
if (!t.title) return;
|
|
if (t.title === root) {
|
|
rootUsed = true;
|
|
return;
|
|
}
|
|
const m = t.title.match(
|
|
new RegExp(
|
|
`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
|
|
),
|
|
);
|
|
if (m) {
|
|
const n = parseInt(m[1], 10);
|
|
if (!isNaN(n)) usedNumbers.add(n);
|
|
}
|
|
});
|
|
|
|
if (!rootUsed) return root;
|
|
let n = 2;
|
|
while (usedNumbers.has(n)) n += 1;
|
|
return `${root} (${n})`;
|
|
}
|
|
|
|
const addTab = (tabData: Omit<Tab, "id">): number => {
|
|
if (tabData.type === "ssh_manager") {
|
|
const existingTab = tabs.find((t) => t.type === "ssh_manager");
|
|
if (existingTab) {
|
|
setTabs((prev) =>
|
|
prev.map((t) =>
|
|
t.id === existingTab.id
|
|
? {
|
|
...t,
|
|
title: existingTab.title,
|
|
hostConfig: tabData.hostConfig
|
|
? { ...tabData.hostConfig }
|
|
: undefined,
|
|
initialTab: tabData.initialTab,
|
|
_updateTimestamp: Date.now(),
|
|
}
|
|
: t,
|
|
),
|
|
);
|
|
setCurrentTab(existingTab.id);
|
|
setAllSplitScreenTab((prev) =>
|
|
prev.filter((tid) => tid !== existingTab.id),
|
|
);
|
|
return existingTab.id;
|
|
}
|
|
}
|
|
|
|
const id = nextTabId.current++;
|
|
const needsUniqueTitle =
|
|
tabData.type === "terminal" ||
|
|
tabData.type === "server" ||
|
|
tabData.type === "file_manager" ||
|
|
tabData.type === "tunnel" ||
|
|
tabData.type === "docker";
|
|
const effectiveTitle = needsUniqueTitle
|
|
? computeUniqueTitle(tabData.type, tabData.title)
|
|
: tabData.title || "";
|
|
const newTab: Tab = {
|
|
...tabData,
|
|
id,
|
|
title: effectiveTitle,
|
|
terminalRef:
|
|
tabData.type === "terminal"
|
|
? React.createRef<{ disconnect?: () => void }>()
|
|
: undefined,
|
|
};
|
|
setTabs((prev) => [...prev, newTab]);
|
|
setCurrentTab(id);
|
|
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
|
return id;
|
|
};
|
|
|
|
const removeTab = (tabId: number) => {
|
|
const tab = tabs.find((t) => t.id === tabId);
|
|
if (
|
|
tab &&
|
|
tab.terminalRef?.current &&
|
|
typeof tab.terminalRef.current.disconnect === "function"
|
|
) {
|
|
tab.terminalRef.current.disconnect();
|
|
}
|
|
|
|
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
|
|
|
// Remove from split screen
|
|
setAllSplitScreenTab((prev) => {
|
|
const newSplits = prev.filter((id) => id !== tabId);
|
|
// Auto-clear split mode if only 1 or fewer tabs remain in split
|
|
if (newSplits.length <= 1) {
|
|
return [];
|
|
}
|
|
return newSplits;
|
|
});
|
|
|
|
if (currentTab === tabId) {
|
|
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
|
|
if (remainingTabs.length > 0) {
|
|
// Try to set current tab to another split tab first, if any remain
|
|
const remainingSplitTabs = allSplitScreenTab.filter(
|
|
(id) => id !== tabId,
|
|
);
|
|
if (remainingSplitTabs.length > 0) {
|
|
setCurrentTab(remainingSplitTabs[0]);
|
|
} else {
|
|
setCurrentTab(remainingTabs[0].id);
|
|
}
|
|
} else {
|
|
setCurrentTab(1); // Home tab
|
|
}
|
|
}
|
|
};
|
|
|
|
const setSplitScreenTab = (tabId: number) => {
|
|
setAllSplitScreenTab((prev) => {
|
|
if (prev.includes(tabId)) {
|
|
return prev.filter((id) => id !== tabId);
|
|
} else if (prev.length < 4) {
|
|
return [...prev, tabId];
|
|
}
|
|
return prev;
|
|
});
|
|
};
|
|
|
|
const getTab = (tabId: number) => {
|
|
return tabs.find((tab) => tab.id === tabId);
|
|
};
|
|
|
|
const isReorderingRef = useRef(false);
|
|
|
|
const reorderTabs = (fromIndex: number, toIndex: number) => {
|
|
if (isReorderingRef.current) return;
|
|
|
|
isReorderingRef.current = true;
|
|
|
|
setTabs((prev) => {
|
|
const newTabs = [...prev];
|
|
const [movedTab] = newTabs.splice(fromIndex, 1);
|
|
|
|
const maxIndex = newTabs.length;
|
|
const safeToIndex = Math.min(toIndex, maxIndex);
|
|
|
|
newTabs.splice(safeToIndex, 0, movedTab);
|
|
|
|
setTimeout(() => {
|
|
isReorderingRef.current = false;
|
|
}, 100);
|
|
|
|
return newTabs;
|
|
});
|
|
};
|
|
|
|
const updateHostConfig = (
|
|
hostId: number,
|
|
newHostConfig: {
|
|
id: number;
|
|
name?: string;
|
|
username: string;
|
|
ip: string;
|
|
port: number;
|
|
},
|
|
) => {
|
|
setTabs((prev) =>
|
|
prev.map((tab) => {
|
|
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
|
if (tab.type === "ssh_manager") {
|
|
return {
|
|
...tab,
|
|
hostConfig: newHostConfig,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...tab,
|
|
hostConfig: newHostConfig,
|
|
title: newHostConfig.name?.trim()
|
|
? newHostConfig.name
|
|
: t("nav.hostTabTitle", {
|
|
username: newHostConfig.username,
|
|
ip: newHostConfig.ip,
|
|
port: newHostConfig.port,
|
|
}),
|
|
};
|
|
}
|
|
return tab;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
|
setTabs((prev) =>
|
|
prev.map((tab) =>
|
|
tab.id === tabId
|
|
? { ...tab, ...updates, _updateTimestamp: Date.now() }
|
|
: tab,
|
|
),
|
|
);
|
|
};
|
|
|
|
const value: TabContextType = {
|
|
tabs,
|
|
currentTab,
|
|
allSplitScreenTab,
|
|
addTab,
|
|
removeTab,
|
|
setCurrentTab,
|
|
setSplitScreenTab,
|
|
getTab,
|
|
reorderTabs,
|
|
updateHostConfig,
|
|
updateTab,
|
|
};
|
|
|
|
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
|
}
|