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:
Kang committed 2026-02-24 15:32:33 +08:00
1 parent 87b01585b3
commit 5be7b2956b
10 files changed
+535 -226

No files matched your search

+35 -9
View File
@@ -15,15 +15,41 @@ struct OutputBuffer {
buffer: Vec<String>,
}
fn get_default_shell() -> CommandBuilder {
#[cfg(target_os = "windows")]
{
CommandBuilder::new("powershell.exe")
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;
}
}
}
#[cfg(not(target_os = "windows"))]
{
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
CommandBuilder::new(shell)
if shell_cmd.is_empty() {
#[cfg(target_os = "windows")]
{
shell_cmd = "powershell.exe".to_string();
}
#[cfg(not(target_os = "windows"))]
{
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
View File
@@ -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 } => {
+236 -188
View File
@@ -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,219 +149,263 @@ 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>
<div
className="font-display h-screen flex flex-col overflow-hidden"
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
>
{/* Header */}
<Header
onNewSession={handleNewSession}
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
onAbout={() => setShowAbout(true)}
/>
<TransferProvider>
<div
className="font-display h-screen flex flex-col overflow-hidden"
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
>
{/* Header */}
<Header
onNewSession={handleNewSession}
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
onAbout={() => setShowAbout(true)}
/>
{/* Main Content */}
<main className="flex-1 flex overflow-hidden relative">
{/* Backdrop for mobile */}
{(mobileLeftOpen || mobileRightOpen) && (
<div
className="absolute inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => {
setMobileLeftOpen(false);
setMobileRightOpen(false);
}}
/>
)}
{/* Left Sidebar - File Explorer */}
{uiConfig.show_file_explorer && (
<>
{/* Main Content */}
<main className="flex-1 flex overflow-hidden relative">
{/* Backdrop for mobile */}
{(mobileLeftOpen || mobileRightOpen) && (
<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
className="absolute inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => {
setMobileLeftOpen(false);
setMobileRightOpen(false);
}}
/>
)}
{/* 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 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">
<FileExplorer activeSessionId={activeTab?.sessionId ?? null} />
</div>
</div>
{/* Only show resize handle on desktop */}
<ResizeHandle
direction="horizontal"
onResize={handleLeftResize}
className="hidden lg:block"
/>
</>
)}
{/* Center - Terminal Area */}
<section
className="flex-1 flex flex-col relative min-w-0 origin-top-left"
style={{
backgroundColor: "var(--df-bg-terminal)",
zoom: uiConfig.zoom_level
}}
>
{/* Tab Bar */}
<TabBar
tabs={tabs}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabClose={closeTab}
onAddTab={handleNewSession}
/>
{/* Terminal Instances */}
<div className="flex-1 relative overflow-hidden">
{tabs.length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-500">
<div className="text-center space-y-3">
<span className="material-icons text-4xl">terminal</span>
<p className="text-sm">{t("app.noActiveSessions")}</p>
<button
className="px-4 py-2 text-xs bg-primary hover:bg-primary/80 text-white rounded transition-colors"
onClick={handleNewSession}
>
{t("app.newConnection")}
</button>
</div>
</div>
) : (
tabs.map((tab) => (
<XTerminal
key={tab.sessionId}
sessionId={tab.sessionId}
active={activeTabId === tab.id}
/>
))
)}
</div>
{/* Quick Commands Bar */}
{uiConfig.show_quick_commands && (
<>
<ResizeHandle direction="vertical" onResize={handleQuickCmdResize} />
<div
style={{ height: uiConfig.quick_cmd_height }}
className="shrink-0 overflow-hidden"
>
<QuickCommands onSend={handleHistoryCommand} />
</div>
</>
)}
</section>
{/* Right Sidebar: three independent panels */}
{(uiConfig.show_saved_connections ||
uiConfig.show_active_sessions ||
uiConfig.show_command_history) && (
<>
{/* Only show resize handle on desktop */}
<ResizeHandle
direction="horizontal"
onResize={handleRightResize}
className="hidden md:block"
/>
<aside
style={{
width: uiConfig.right_width,
backgroundColor: "var(--df-bg-panel)",
borderColor: "var(--df-border)",
}}
className={`
fixed inset-y-0 right-0 z-50 flex flex-col shadow-xl transition-transform duration-200 border-l
md:relative md:translate-x-0 md:z-0 md:shadow-none
${mobileRightOpen ? "translate-x-0" : "translate-x-full"}
`}
>
{/* Mobile placeholder for header height if needed, or close button */}
<div
className="md:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
className="lg:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
style={{ borderColor: "var(--df-border)" }}
>
<button
onClick={() => setMobileRightOpen(false)}
onClick={() => setMobileLeftOpen(false)}
style={{ color: "var(--df-text-muted)" }}
>
<span className="material-icons">close</span>
</button>
</div>
{/* Saved Connections - fixed pixel height at top */}
{uiConfig.show_saved_connections && (
<>
<div
style={{ height: uiConfig.saved_conn_height }}
className="shrink-0 overflow-hidden"
>
<SavedConnections
onEditConnection={handleEditConnection}
onSessionCreated={handleSessionConnected}
/>
<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>
{/* Show resize handle only if there's something below it */}
{(uiConfig.show_active_sessions || uiConfig.show_command_history) && (
<ResizeHandle direction="vertical" onResize={handleSavedConnResize} />
)}
</>
)}
)}
{/* Active Sessions - flexible middle */}
{uiConfig.show_active_sessions && (
<div className="flex-1 min-h-0 overflow-hidden">
<ActiveSessions onSessionClick={handleSessionClick} />
</div>
)}
{/* Command History - fixed pixel height at bottom */}
{uiConfig.show_command_history && (
<>
{/* Show resize handle only if there's something above it */}
{(uiConfig.show_saved_connections || uiConfig.show_active_sessions) && (
<ResizeHandle direction="vertical" onResize={handleHistoryResize} />
)}
<div
style={{ height: uiConfig.history_height }}
className="shrink-0 overflow-hidden"
>
<CommandHistory onCommandSend={handleHistoryCommand} />
</div>
</>
)}
</aside>
{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
direction="horizontal"
onResize={handleLeftResize}
className="hidden lg:block"
/>
</>
)}
</main>
{/* Status Bar */}
<StatusBar />
{/* Center - Terminal Area */}
<section
className="flex-1 flex flex-col relative min-w-0 origin-top-left"
style={{
backgroundColor: "var(--df-bg-terminal)",
zoom: uiConfig.zoom_level
}}
>
{/* Tab Bar */}
<TabBar
tabs={tabs}
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabClose={closeTab}
onAddTab={handleNewSession}
/>
{/* New Session Dialog */}
<NewSessionDialog
open={showNewSession}
onClose={() => {
setShowNewSession(false);
setEditingConnection(undefined);
}}
onConnect={handleSessionConnected}
onSaved={() => {
setShowNewSession(false);
setEditingConnection(undefined);
refreshConnections();
}}
initialData={editingConnection}
/>
{/* Terminal Instances */}
<div className="flex-1 relative overflow-hidden">
{tabs.length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-500">
<div className="text-center space-y-3">
<span className="material-icons text-4xl">terminal</span>
<p className="text-sm">{t("app.noActiveSessions")}</p>
<button
className="px-4 py-2 text-xs bg-primary hover:bg-primary/80 text-white rounded transition-colors"
onClick={handleNewSession}
>
{t("app.newConnection")}
</button>
</div>
</div>
) : (
tabs.map((tab) => (
<XTerminal
key={tab.sessionId}
sessionId={tab.sessionId}
active={activeTabId === tab.id}
/>
))
)}
</div>
<AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
{/* Quick Commands Bar */}
{uiConfig.show_quick_commands && (
<>
<ResizeHandle direction="vertical" onResize={handleQuickCmdResize} />
<div
style={{ height: uiConfig.quick_cmd_height }}
className="shrink-0 overflow-hidden"
>
<QuickCommands onSend={handleHistoryCommand} />
</div>
</>
)}
</section>
<ToastContainer />
</div>
{/* Right Sidebar: three independent panels */}
{(uiConfig.show_saved_connections ||
uiConfig.show_active_sessions ||
uiConfig.show_command_history) && (
<>
{/* Only show resize handle on desktop */}
<ResizeHandle
direction="horizontal"
onResize={handleRightResize}
className="hidden md:block"
/>
<aside
style={{
width: uiConfig.right_width,
backgroundColor: "var(--df-bg-panel)",
borderColor: "var(--df-border)",
}}
className={`
fixed inset-y-0 right-0 z-50 flex flex-col shadow-xl transition-transform duration-200 border-l
md:relative md:translate-x-0 md:z-0 md:shadow-none
${mobileRightOpen ? "translate-x-0" : "translate-x-full"}
`}
>
{/* Mobile placeholder for header height if needed, or close button */}
<div
className="md:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
style={{ borderColor: "var(--df-border)" }}
>
<button
onClick={() => setMobileRightOpen(false)}
style={{ color: "var(--df-text-muted)" }}
>
<span className="material-icons">close</span>
</button>
</div>
{/* Saved Connections - fixed pixel height at top */}
{uiConfig.show_saved_connections && (
<>
<div
style={{ height: uiConfig.saved_conn_height }}
className="shrink-0 overflow-hidden"
>
<SavedConnections
onEditConnection={handleEditConnection}
onSessionCreated={handleSessionConnected}
/>
</div>
{/* Show resize handle only if there's something below it */}
{(uiConfig.show_active_sessions || uiConfig.show_command_history) && (
<ResizeHandle direction="vertical" onResize={handleSavedConnResize} />
)}
</>
)}
{/* Active Sessions - flexible middle */}
{uiConfig.show_active_sessions && (
<div className="flex-1 min-h-0 overflow-hidden">
<ActiveSessions onSessionClick={handleSessionClick} />
</div>
)}
{/* Command History - fixed pixel height at bottom */}
{uiConfig.show_command_history && (
<>
{/* Show resize handle only if there's something above it */}
{(uiConfig.show_saved_connections || uiConfig.show_active_sessions) && (
<ResizeHandle direction="vertical" onResize={handleHistoryResize} />
)}
<div
style={{ height: uiConfig.history_height }}
className="shrink-0 overflow-hidden"
>
<CommandHistory onCommandSend={handleHistoryCommand} />
</div>
</>
)}
</aside>
</>
)}
</main>
{/* Status Bar */}
<StatusBar />
{/* New Session Dialog */}
<NewSessionDialog
open={showNewSession}
onClose={() => {
setShowNewSession(false);
setEditingConnection(undefined);
}}
onConnect={handleSessionConnected}
onSaved={() => {
setShowNewSession(false);
setEditingConnection(undefined);
refreshConnections();
}}
initialData={editingConnection}
/>
<AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
<SettingsDialog />
<GlobalContextMenu />
<ToastContainer />
</div>
</TransferProvider>
</ToastProvider>
);
}
+10
View File
@@ -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();
+11 -9
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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}
+10 -9
View File
@@ -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 (
+2
View File
@@ -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>