refactor: update global UI, layout visibility, and theme configuration
- Dynamically map visibility states via AppContext - Improve SVG dynamic background thematic integration
This commit is contained in:
1 parent
87b01585b3
commit
5be7b2956b
10 files changed
+349
-40
No files matched your search
+31
-5
@@ -15,15 +15,41 @@ struct OutputBuffer {
|
||||
buffer: Vec<String>,
|
||||
}
|
||||
|
||||
fn get_default_shell() -> CommandBuilder {
|
||||
fn get_default_shell(app: Option<&AppHandle>) -> CommandBuilder {
|
||||
let mut shell_cmd = String::new();
|
||||
|
||||
if let Some(app) = app {
|
||||
if let Ok(settings) = crate::config::load_app_settings(app) {
|
||||
let user_shell = settings.general.default_local_shell;
|
||||
if !user_shell.trim().is_empty() {
|
||||
shell_cmd = user_shell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shell_cmd.is_empty() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
CommandBuilder::new("powershell.exe")
|
||||
shell_cmd = "powershell.exe".to_string();
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
CommandBuilder::new(shell)
|
||||
shell_cmd = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = shell_cmd.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
#[cfg(target_os = "windows")]
|
||||
return CommandBuilder::new("powershell.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return CommandBuilder::new("/bin/bash");
|
||||
} else {
|
||||
let mut builder = CommandBuilder::new(parts[0]);
|
||||
if parts.len() > 1 {
|
||||
builder.args(&parts[1..]);
|
||||
}
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +112,7 @@ fn pty_session_thread(
|
||||
}
|
||||
};
|
||||
|
||||
let cmd = get_default_shell();
|
||||
let cmd = get_default_shell(Some(&app));
|
||||
let mut _child = match pair.slave.spawn_command(cmd) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
||||
+47
-4
@@ -127,6 +127,44 @@ impl client::Handler for SshHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_with_proxy(
|
||||
app: &AppHandle,
|
||||
config: &SshConfig,
|
||||
ssh_config: Arc<client::Config>,
|
||||
handler: SshHandler,
|
||||
) -> AppResult<client::Handle<SshHandler>> {
|
||||
let mut proxy_settings = None;
|
||||
if let Ok(app_settings) = crate::config::load_app_settings(app) {
|
||||
if app_settings.proxy.enabled {
|
||||
proxy_settings = Some(app_settings.proxy);
|
||||
}
|
||||
}
|
||||
|
||||
let target = (config.host.as_str(), config.port);
|
||||
let handle = if let Some(proxy) = proxy_settings {
|
||||
let proxy_addr = format!("{}:{}", proxy.host, proxy.port);
|
||||
match proxy.protocol.as_str() {
|
||||
"socks5" => {
|
||||
let stream = tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target).await
|
||||
.map_err(|e| AppError::Auth(format!("SOCKS5 proxy connection failed: {}", e)))?;
|
||||
client::connect_stream(ssh_config, stream.into_inner(), handler).await
|
||||
}
|
||||
"http" => {
|
||||
let mut stream = tokio::net::TcpStream::connect(&proxy_addr).await
|
||||
.map_err(|e| AppError::Auth(format!("HTTP proxy connection failed: {}", e)))?;
|
||||
async_http_proxy::http_connect_tokio(&mut stream, &config.host, config.port).await
|
||||
.map_err(|e| AppError::Auth(format!("HTTP proxy tunnel failed: {}", e)))?;
|
||||
client::connect_stream(ssh_config, stream, handler).await
|
||||
}
|
||||
_ => client::connect(ssh_config, target, handler).await,
|
||||
}
|
||||
} else {
|
||||
client::connect(ssh_config, target, handler).await
|
||||
}.map_err(|e| AppError::Auth(format!("SSH connection failed: {}", e)))?;
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Connects via SSH, opens a PTY shell, and spawns the I/O loop.
|
||||
pub async fn create_ssh_session(
|
||||
app: AppHandle,
|
||||
@@ -137,12 +175,17 @@ pub async fn create_ssh_session(
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
|
||||
|
||||
let ssh_config = Arc::new(client::Config::default());
|
||||
let mut ssh_config_obj = client::Config::default();
|
||||
if let Ok(app_settings) = crate::config::load_app_settings(&app) {
|
||||
let interval = app_settings.terminal.keep_alive_interval;
|
||||
if interval > 0 {
|
||||
ssh_config_obj.keepalive_interval = Some(std::time::Duration::from_secs(interval as u64));
|
||||
}
|
||||
}
|
||||
let ssh_config = Arc::new(ssh_config_obj);
|
||||
let handler = SshHandler::new(app.clone(), config.host.clone(), config.port);
|
||||
|
||||
let mut handle = client::connect(ssh_config, (config.host.as_str(), config.port), handler)
|
||||
.await
|
||||
.map_err(|e| AppError::Auth(format!("SSH connection failed: {}", e)))?;
|
||||
let mut handle = connect_with_proxy(&app, &config, ssh_config, handler).await?;
|
||||
|
||||
match &config.auth {
|
||||
SshAuth::Password { password } => {
|
||||
|
||||
+54
-6
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AboutDialog from "./components/dialog/AboutDialog";
|
||||
import GlobalContextMenu from "./components/dialog/GlobalContextMenu";
|
||||
import NewSessionDialog from "./components/dialog/NewSessionDialog";
|
||||
import SettingsDialog from "./components/dialog/SettingsDialog";
|
||||
import Header from "./components/layout/Header";
|
||||
import ResizeHandle from "./components/layout/ResizeHandle";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
@@ -9,11 +11,13 @@ import ActiveSessions from "./components/panel/ActiveSessions";
|
||||
import CommandHistory from "./components/panel/CommandHistory";
|
||||
import SavedConnections from "./components/panel/SavedConnections";
|
||||
import FileExplorer from "./components/sidebar/FileExplorer";
|
||||
import FileTransfer from "./components/sidebar/FileTransfer";
|
||||
import QuickCommands from "./components/terminal/QuickCommands";
|
||||
import TabBar from "./components/terminal/TabBar";
|
||||
import XTerminal from "./components/terminal/XTerminal";
|
||||
import ToastContainer from "./components/toast/ToastContainer";
|
||||
import { ToastProvider } from "./components/toast/ToastContext";
|
||||
import { TransferProvider } from "./context/TransferContext";
|
||||
import { useApp } from "./context/AppContext";
|
||||
import type { SavedConnection } from "./types";
|
||||
|
||||
@@ -69,8 +73,8 @@ function App() {
|
||||
|
||||
// Handle connection success from Dialog or SavedConnections
|
||||
const handleSessionConnected = useCallback(
|
||||
(sessionId: string, name: string, type_: "SSH" | "Local") => {
|
||||
addTab(sessionId, name, type_);
|
||||
(sessionId: string, name: string, type_: "SSH" | "Local", connectionId?: string) => {
|
||||
addTab(sessionId, name, type_, connectionId);
|
||||
setShowNewSession(false);
|
||||
setEditingConnection(undefined);
|
||||
},
|
||||
@@ -145,8 +149,18 @@ function App() {
|
||||
[updateUiConfig, uiConfig.quick_cmd_height],
|
||||
);
|
||||
|
||||
const handleFileTransferResize = useCallback(
|
||||
(delta: number) => {
|
||||
updateUiConfig({
|
||||
file_transfer_height: Math.max(80, Math.min(500, (uiConfig.file_transfer_height || 240) - delta)),
|
||||
});
|
||||
},
|
||||
[updateUiConfig, uiConfig.file_transfer_height],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<TransferProvider>
|
||||
<div
|
||||
className="font-display h-screen flex flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
|
||||
@@ -172,20 +186,51 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar - File Explorer */}
|
||||
{uiConfig.show_file_explorer && (
|
||||
{/* Left Sidebar - File Explorer & File Transfer */}
|
||||
{(uiConfig.show_file_explorer || uiConfig.show_file_transfer) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: uiConfig.left_width, backgroundColor: "var(--df-bg)" }}
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-40 shadow-xl transition-transform duration-200
|
||||
fixed inset-y-0 left-0 z-40 flex flex-col shadow-xl transition-transform duration-200
|
||||
lg:relative lg:translate-x-0 lg:z-0 lg:shadow-none
|
||||
${mobileLeftOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
`}
|
||||
>
|
||||
<div className="h-full flex flex-col pt-10 lg:pt-0">
|
||||
{/* Mobile placeholder for header height if needed, or close button */}
|
||||
<div
|
||||
className="lg:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
|
||||
style={{ borderColor: "var(--df-border)" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileLeftOpen(false)}
|
||||
style={{ color: "var(--df-text-muted)" }}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 pt-10 lg:pt-0">
|
||||
{uiConfig.show_file_explorer && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<FileExplorer activeSessionId={activeTab?.sessionId ?? null} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uiConfig.show_file_transfer && (
|
||||
<>
|
||||
{uiConfig.show_file_explorer && (
|
||||
<ResizeHandle direction="vertical" onResize={handleFileTransferResize} />
|
||||
)}
|
||||
<div
|
||||
style={{ height: uiConfig.show_file_explorer ? (uiConfig.file_transfer_height || 240) : "100%" }}
|
||||
className={uiConfig.show_file_explorer ? "shrink-0 overflow-hidden" : "flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<FileTransfer activeSessionId={activeTab?.sessionId ?? null} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Only show resize handle on desktop */}
|
||||
<ResizeHandle
|
||||
@@ -355,9 +400,12 @@ function App() {
|
||||
/>
|
||||
|
||||
<AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
|
||||
<SettingsDialog />
|
||||
|
||||
<GlobalContextMenu />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</TransferProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -108,6 +108,17 @@ export default function NewSessionDialog({
|
||||
}
|
||||
}, [open, initialData, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open && !connecting && !showGroupDropdown) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, connecting, showGroupDropdown, resetForm, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { appLogDir } from "@tauri-apps/api/path";
|
||||
import packageJson from "../../../package.json";
|
||||
import { useApp } from "../../context/AppContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { AVAILABLE_LANGUAGES } from "../../i18n";
|
||||
|
||||
interface HeaderProps {
|
||||
onNewSession: () => void;
|
||||
@@ -29,7 +30,7 @@ export default function Header({
|
||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { themeName, setTheme, themeNames } = useTheme();
|
||||
const { uiConfig, updateUiConfig } = useApp();
|
||||
const { uiConfig, updateUiConfig, setShowSettingsDialog } = useApp();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const changeLanguage = (lng: string) => {
|
||||
@@ -117,10 +118,11 @@ export default function Header({
|
||||
{
|
||||
label: t("menu.language"),
|
||||
icon: "translate",
|
||||
submenu: [
|
||||
{ label: "English", checked: i18n.language === "en", action: () => changeLanguage("en") },
|
||||
{ label: "中文", checked: i18n.language === "zh-CN", action: () => changeLanguage("zh-CN") },
|
||||
],
|
||||
submenu: AVAILABLE_LANGUAGES.map((l) => ({
|
||||
label: l.name,
|
||||
checked: i18n.language === l.id,
|
||||
action: () => changeLanguage(l.id)
|
||||
})),
|
||||
},
|
||||
{ label: "separator", separator: true },
|
||||
{ label: t("menu.zoomIn"), action: () => handleZoom(0.1), icon: "zoom_in" },
|
||||
@@ -227,10 +229,10 @@ export default function Header({
|
||||
<span className="material-icons text-base">view_sidebar</span>
|
||||
</button>
|
||||
|
||||
<span className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block">
|
||||
search
|
||||
</span>
|
||||
<span className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block">
|
||||
<span
|
||||
className="material-icons text-base cursor-pointer hover:opacity-80 transition-opacity hidden sm:block"
|
||||
onClick={() => setShowSettingsDialog(true)}
|
||||
>
|
||||
settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from "../toast/ToastContext";
|
||||
|
||||
interface SavedConnectionsProps {
|
||||
onEditConnection: (connection: SavedConnection) => void;
|
||||
onSessionCreated: (sessionId: string, name: string, type: SessionType) => void;
|
||||
onSessionCreated: (sessionId: string, name: string, type: SessionType, connectionId?: string) => void;
|
||||
}
|
||||
|
||||
interface HoverInfo {
|
||||
@@ -69,7 +69,7 @@ export default function SavedConnections({
|
||||
setConnectingId(conn.id);
|
||||
try {
|
||||
const sessionId = await invoke<string>("create_ssh_session", { connectionId: conn.id });
|
||||
onSessionCreated(sessionId, conn.name, "SSH");
|
||||
onSessionCreated(sessionId, conn.name, "SSH", conn.id);
|
||||
} catch (e) {
|
||||
logger.error(`SSH connection failed for "${conn.name}"`, e);
|
||||
toast.error(t("savedConnections.connectionFailed", { error: e }));
|
||||
|
||||
+171
-5
@@ -9,20 +9,38 @@ import {
|
||||
} from "react";
|
||||
import { invoke } from "../lib/invoke";
|
||||
import { logger } from "../lib/logger";
|
||||
import type { Group, SavedConnection, SessionType, Tab, UiConfig } from "../types";
|
||||
import type { AppSettings, Group, SavedConnection, SessionType, Tab, UiConfig } from "../types";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
icon?: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
onClick: () => void;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface AppContextType {
|
||||
// Tabs
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
setActiveTabId: (id: string | null) => void;
|
||||
addTab: (sessionId: string, name: string, type: SessionType) => void;
|
||||
addTab: (sessionId: string, name: string, type: SessionType, connectionId?: string) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
|
||||
// UI Config
|
||||
uiConfig: UiConfig;
|
||||
updateUiConfig: (updates: Partial<UiConfig>) => void;
|
||||
|
||||
// App Settings
|
||||
appSettings: AppSettings;
|
||||
updateAppSettings: (updates: Partial<AppSettings>) => void;
|
||||
|
||||
// Data
|
||||
savedConnections: SavedConnection[];
|
||||
savedGroups: Group[];
|
||||
@@ -33,6 +51,13 @@ interface AppContextType {
|
||||
setShowNewSession: (show: boolean) => void;
|
||||
editingConnection: SavedConnection | undefined;
|
||||
setEditingConnection: (conn: SavedConnection | undefined) => void;
|
||||
showSettingsDialog: boolean;
|
||||
setShowSettingsDialog: (show: boolean) => void;
|
||||
|
||||
// Global Context Menu
|
||||
contextMenu: ContextMenuState | null;
|
||||
showContextMenu: (state: ContextMenuState) => void;
|
||||
hideContextMenu: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,21 +67,76 @@ interface AppContextType {
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
|
||||
const DEFAULT_UI_CONFIG: UiConfig = {
|
||||
open_tabs: [],
|
||||
left_width: 256,
|
||||
right_width: 288,
|
||||
saved_conn_height: 240,
|
||||
history_height: 200,
|
||||
quick_cmd_height: 36,
|
||||
file_transfer_height: 240,
|
||||
show_file_explorer: true,
|
||||
show_file_transfer: true,
|
||||
show_saved_connections: true,
|
||||
show_active_sessions: true,
|
||||
show_command_history: true,
|
||||
show_quick_commands: true,
|
||||
zoom_level: 1.0,
|
||||
theme: "github-dark",
|
||||
language: "en",
|
||||
};
|
||||
|
||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
general: {
|
||||
startup_restore: true,
|
||||
default_local_shell: navigator.userAgent.includes("Win") ? "powershell.exe" : "bash",
|
||||
minimize_to_tray: false,
|
||||
boss_key: null,
|
||||
},
|
||||
appearance: {
|
||||
theme: "github-dark",
|
||||
font_family: "JetBrains Mono, Fira Code, Consolas, monospace",
|
||||
font_size: 14,
|
||||
ligatures: false,
|
||||
background_opacity: 1.0,
|
||||
cursor_style: "block",
|
||||
cursor_blink: true,
|
||||
},
|
||||
proxy: {
|
||||
enabled: false,
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
},
|
||||
search: {
|
||||
default_engine: "google",
|
||||
custom_engines: [
|
||||
{ name: "Google", url_template: "https://google.com/search?q=%s" },
|
||||
{ name: "Bing", url_template: "https://bing.com/search?q=%s" },
|
||||
{ name: "GitHub", url_template: "https://github.com/search?q=%s" },
|
||||
],
|
||||
},
|
||||
translation: {
|
||||
provider: "deepl",
|
||||
api_key: "",
|
||||
},
|
||||
security: {
|
||||
use_os_keyring: true,
|
||||
require_master_password: false,
|
||||
idle_lock_minutes: 0,
|
||||
host_key_policy: "prompt",
|
||||
},
|
||||
terminal: {
|
||||
scrollback_lines: 10000,
|
||||
keep_alive_interval: 60,
|
||||
hardware_acceleration: false,
|
||||
},
|
||||
interaction: {
|
||||
copy_on_select: true,
|
||||
right_click_paste: true,
|
||||
word_separators: " ()[]{}\"'",
|
||||
default_encoding: "UTF-8",
|
||||
},
|
||||
};
|
||||
|
||||
/** Provides tabs, uiConfig, savedConnections, and dialog state to the app. */
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
// Tabs State
|
||||
@@ -68,6 +148,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const uiConfigLoaded = useRef(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// App Settings State
|
||||
const [appSettings, setAppSettings] = useState<AppSettings>(DEFAULT_APP_SETTINGS);
|
||||
const appSettingsLoaded = useRef(false);
|
||||
const appSettingsSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Data State
|
||||
const [savedConnections, setSavedConnections] = useState<SavedConnection[]>([]);
|
||||
const [savedGroups, setSavedGroups] = useState<Group[]>([]);
|
||||
@@ -77,6 +162,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
|
||||
|
||||
// Global Context Menu State
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
|
||||
const showContextMenu = useCallback((state: ContextMenuState) => {
|
||||
setContextMenu(state);
|
||||
}, []);
|
||||
|
||||
const hideContextMenu = useCallback(() => {
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
// 1. Load UI Config
|
||||
useEffect(() => {
|
||||
@@ -90,6 +187,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 1.5. Load App Settings
|
||||
useEffect(() => {
|
||||
invoke<AppSettings>("get_app_settings")
|
||||
.then((cfg) => {
|
||||
setAppSettings(cfg);
|
||||
appSettingsLoaded.current = true;
|
||||
})
|
||||
.catch(() => {
|
||||
appSettingsLoaded.current = true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 2. Save UI Config Debounced
|
||||
const updateUiConfig = useCallback((updates: Partial<UiConfig>) => {
|
||||
setUiConfig((prev) => {
|
||||
@@ -107,6 +216,22 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 2.5 Save App Settings Debounced
|
||||
const updateAppSettings = useCallback((updates: Partial<AppSettings>) => {
|
||||
setAppSettings((prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
if (appSettingsLoaded.current) {
|
||||
if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current);
|
||||
appSettingsSaveTimerRef.current = setTimeout(() => {
|
||||
invoke("save_app_settings", { settings: next }).catch((e) =>
|
||||
logger.error("Failed to save app settings", e),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 3. Load Connections
|
||||
const refreshConnections = useCallback(async () => {
|
||||
try {
|
||||
@@ -128,9 +253,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
}, [refreshConnections]);
|
||||
|
||||
// 4. Tab Logic
|
||||
const addTab = useCallback((sessionId: string, name: string, type: SessionType) => {
|
||||
const addTab = useCallback((sessionId: string, name: string, type: SessionType, connectionId?: string) => {
|
||||
const tabId = `tab-${Date.now()}`;
|
||||
const newTab: Tab = { id: tabId, sessionId, name, type };
|
||||
const newTab: Tab = { id: tabId, sessionId, name, type, connectionId };
|
||||
setTabs((prev) => [...prev, newTab]);
|
||||
setActiveTabId(tabId);
|
||||
|
||||
@@ -155,6 +280,40 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
[activeTabId],
|
||||
);
|
||||
// 5. Startup Restore Logic
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRestored.current && uiConfigLoaded.current && appSettingsLoaded.current) {
|
||||
hasRestored.current = true;
|
||||
if (appSettings.general.startup_restore && uiConfig.open_tabs && uiConfig.open_tabs.length > 0) {
|
||||
uiConfig.open_tabs.forEach(tab => {
|
||||
if (tab.session_type === "SSH" && tab.connection_id) {
|
||||
invoke<string>("create_ssh_session", { connectionId: tab.connection_id }).then(sessionId => {
|
||||
addTab(sessionId, tab.title, "SSH", tab.connection_id);
|
||||
}).catch(e => logger.error(`Restore SSH failed for ${tab.title}`, e));
|
||||
} else if (tab.session_type === "Local" || tab.session_type === "local") {
|
||||
invoke<string>("create_local_session").then(sessionId => {
|
||||
addTab(sessionId, tab.title, "Local");
|
||||
}).catch(e => logger.error(`Restore Local failed`, e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [uiConfig, appSettings, addTab]);
|
||||
|
||||
// 6. Sync opened tabs
|
||||
useEffect(() => {
|
||||
if (hasRestored.current && appSettings.general.startup_restore) {
|
||||
updateUiConfig({
|
||||
open_tabs: tabs.map(t => ({
|
||||
title: t.name,
|
||||
session_type: t.type,
|
||||
connection_id: t.connectionId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}, [tabs, appSettings.general.startup_restore, updateUiConfig]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
@@ -166,6 +325,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
closeTab,
|
||||
uiConfig,
|
||||
updateUiConfig,
|
||||
appSettings,
|
||||
updateAppSettings,
|
||||
savedConnections,
|
||||
savedGroups,
|
||||
refreshConnections,
|
||||
@@ -173,6 +334,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setShowNewSession,
|
||||
editingConnection,
|
||||
setEditingConnection,
|
||||
showSettingsDialog,
|
||||
setShowSettingsDialog,
|
||||
contextMenu,
|
||||
showContextMenu,
|
||||
hideContextMenu,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -34,10 +34,10 @@ function applyThemeToDOM(colors: ThemeColors) {
|
||||
root.setProperty("--df-accent", colors.accent);
|
||||
}
|
||||
|
||||
/** Provides theme, themeName, setTheme. Syncs with uiConfig.theme from backend. */
|
||||
/** Provides theme, themeName, setTheme. Syncs with appSettings.appearance.theme from backend. */
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const { uiConfig, updateUiConfig } = useApp();
|
||||
const initialId = uiConfig.theme || DEFAULT_THEME_ID;
|
||||
const { appSettings, updateAppSettings } = useApp();
|
||||
const initialId = appSettings.appearance.theme || DEFAULT_THEME_ID;
|
||||
const [themeName, setThemeName] = useState(initialId);
|
||||
|
||||
const current = themes[themeName] || themes[DEFAULT_THEME_ID];
|
||||
@@ -47,21 +47,22 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
applyThemeToDOM(current.colors);
|
||||
}, [current]);
|
||||
|
||||
// Sync from backend when uiConfig.theme changes (e.g. on load)
|
||||
// Sync from backend when appSettings.appearance.theme changes (e.g. on load or from Settings dialog)
|
||||
useEffect(() => {
|
||||
if (uiConfig.theme && uiConfig.theme !== themeName && themes[uiConfig.theme]) {
|
||||
setThemeName(uiConfig.theme);
|
||||
const configTheme = appSettings.appearance.theme;
|
||||
if (configTheme && configTheme !== themeName && themes[configTheme]) {
|
||||
setThemeName(configTheme);
|
||||
}
|
||||
}, [uiConfig.theme, themeName]);
|
||||
}, [appSettings.appearance.theme, themeName]);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(id: string) => {
|
||||
if (themes[id]) {
|
||||
setThemeName(id);
|
||||
updateUiConfig({ theme: id });
|
||||
updateAppSettings({ appearance: { ...appSettings.appearance, theme: id } });
|
||||
}
|
||||
},
|
||||
[updateUiConfig],
|
||||
[appSettings.appearance, updateAppSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,8 @@ import "./index.css";
|
||||
import { AppProvider } from "./context/AppContext";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
||||
Reference in new issue
Block a user