style: shadcn ui

This commit is contained in:
Kang committed 2026-02-24 16:45:31 +08:00
1 parent 5be7b2956b
commit 21884eb17b
28 files changed
+6026 -1259

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+11 -1
View File
@@ -32,20 +32,30 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.8.11", "i18next": "^25.8.11",
"lucide-react": "^0.575.0",
"material-icons": "^1.13.14", "material-icons": "^1.13.14",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^16.5.4" "react-i18next": "^16.5.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2", "@biomejs/biome": "^2.4.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^25.3.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
+4140 -8
View File
File diff suppressed because it is too large. Load diff
+221 -224
View File
@@ -15,8 +15,7 @@ import FileTransfer from "./components/sidebar/FileTransfer";
import QuickCommands from "./components/terminal/QuickCommands"; import QuickCommands from "./components/terminal/QuickCommands";
import TabBar from "./components/terminal/TabBar"; import TabBar from "./components/terminal/TabBar";
import XTerminal from "./components/terminal/XTerminal"; import XTerminal from "./components/terminal/XTerminal";
import ToastContainer from "./components/toast/ToastContainer"; import { Toaster } from "@/components/ui/sonner";
import { ToastProvider } from "./components/toast/ToastContext";
import { TransferProvider } from "./context/TransferContext"; import { TransferProvider } from "./context/TransferContext";
import { useApp } from "./context/AppContext"; import { useApp } from "./context/AppContext";
import type { SavedConnection } from "./types"; import type { SavedConnection } from "./types";
@@ -159,254 +158,252 @@ function App() {
); );
return ( return (
<ToastProvider> <TransferProvider>
<TransferProvider> <div
<div className="font-display h-screen flex flex-col overflow-hidden"
className="font-display h-screen flex flex-col overflow-hidden" style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }} >
> {/* Header */}
{/* Header */} <Header
<Header onNewSession={handleNewSession}
onNewSession={handleNewSession} onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)} onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)} onAbout={() => setShowAbout(true)}
onAbout={() => setShowAbout(true)} />
/>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 flex overflow-hidden relative"> <main className="flex-1 flex overflow-hidden relative">
{/* Backdrop for mobile */} {/* Backdrop for mobile */}
{(mobileLeftOpen || mobileRightOpen) && ( {(mobileLeftOpen || mobileRightOpen) && (
<div
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 <div
className="absolute inset-0 bg-black/50 z-40 lg:hidden" style={{ width: uiConfig.left_width, backgroundColor: "var(--df-bg)" }}
onClick={() => { className={`
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 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 lg:relative lg:translate-x-0 lg:z-0 lg:shadow-none
${mobileLeftOpen ? "translate-x-0" : "-translate-x-full"} ${mobileLeftOpen ? "translate-x-0" : "-translate-x-full"}
`} `}
>
{/* 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
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 */} {/* Mobile placeholder for header height if needed, or close button */}
<div <div
className="lg:hidden h-10 flex items-center justify-end px-2 border-b shrink-0" className="md:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
style={{ borderColor: "var(--df-border)" }} style={{ borderColor: "var(--df-border)" }}
> >
<button <button
onClick={() => setMobileLeftOpen(false)} onClick={() => setMobileRightOpen(false)}
style={{ color: "var(--df-text-muted)" }} style={{ color: "var(--df-text-muted)" }}
> >
<span className="material-icons">close</span> <span className="material-icons">close</span>
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col min-h-0 pt-10 lg:pt-0"> {/* Saved Connections - fixed pixel height at top */}
{uiConfig.show_file_explorer && ( {uiConfig.show_saved_connections && (
<div className="flex-1 min-h-0 overflow-hidden"> <>
<FileExplorer activeSessionId={activeTab?.sessionId ?? null} /> <div
style={{ height: uiConfig.saved_conn_height }}
className="shrink-0 overflow-hidden"
>
<SavedConnections
onEditConnection={handleEditConnection}
onSessionCreated={handleSessionConnected}
/>
</div> </div>
)} {/* Show resize handle only if there's something below it */}
{(uiConfig.show_active_sessions || uiConfig.show_command_history) && (
<ResizeHandle direction="vertical" onResize={handleSavedConnResize} />
)}
</>
)}
{uiConfig.show_file_transfer && ( {/* Active Sessions - flexible middle */}
<> {uiConfig.show_active_sessions && (
{uiConfig.show_file_explorer && ( <div className="flex-1 min-h-0 overflow-hidden">
<ResizeHandle direction="vertical" onResize={handleFileTransferResize} /> <ActiveSessions onSessionClick={handleSessionClick} />
)} </div>
<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"} {/* Command History - fixed pixel height at bottom */}
> {uiConfig.show_command_history && (
<FileTransfer activeSessionId={activeTab?.sessionId ?? null} /> <>
</div> {/* Show resize handle only if there's something above it */}
</> {(uiConfig.show_saved_connections || uiConfig.show_active_sessions) && (
)} <ResizeHandle direction="vertical" onResize={handleHistoryResize} />
</div> )}
</div> <div
{/* Only show resize handle on desktop */} style={{ height: uiConfig.history_height }}
<ResizeHandle className="shrink-0 overflow-hidden"
direction="horizontal" >
onResize={handleLeftResize} <CommandHistory onCommandSend={handleHistoryCommand} />
className="hidden lg:block" </div>
/> </>
)}
</aside>
</> </>
)} )}
</main>
{/* Center - Terminal Area */} {/* Status Bar */}
<section <StatusBar />
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 */} {/* New Session Dialog */}
<div className="flex-1 relative overflow-hidden"> <NewSessionDialog
{tabs.length === 0 ? ( open={showNewSession}
<div className="flex items-center justify-center h-full text-slate-500"> onClose={() => {
<div className="text-center space-y-3"> setShowNewSession(false);
<span className="material-icons text-4xl">terminal</span> setEditingConnection(undefined);
<p className="text-sm">{t("app.noActiveSessions")}</p> }}
<button onConnect={handleSessionConnected}
className="px-4 py-2 text-xs bg-primary hover:bg-primary/80 text-white rounded transition-colors" onSaved={() => {
onClick={handleNewSession} setShowNewSession(false);
> setEditingConnection(undefined);
{t("app.newConnection")} refreshConnections();
</button> }}
</div> initialData={editingConnection}
</div> />
) : (
tabs.map((tab) => (
<XTerminal
key={tab.sessionId}
sessionId={tab.sessionId}
active={activeTabId === tab.id}
/>
))
)}
</div>
{/* Quick Commands Bar */} <AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
{uiConfig.show_quick_commands && ( <SettingsDialog />
<>
<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 */} <GlobalContextMenu />
{(uiConfig.show_saved_connections || <Toaster position="bottom-right" />
uiConfig.show_active_sessions || </div>
uiConfig.show_command_history) && ( </TransferProvider>
<>
{/* 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>
); );
} }
+34 -48
View File
@@ -4,6 +4,15 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import pkg from "../../../package.json"; import pkg from "../../../package.json";
import DragonflyLogo from "../DragonflyLogo"; import DragonflyLogo from "../DragonflyLogo";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface AboutDialogProps { interface AboutDialogProps {
open: boolean; open: boolean;
@@ -22,69 +31,46 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
} }
}, [open]); }, [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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<div <DialogContent className="w-[320px] sm:max-w-[320px] flex flex-col items-center p-6 gap-4">
className="rounded-lg w-[320px] shadow-2xl border flex flex-col items-center p-6 space-y-4" <DialogHeader className="items-center">
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }} <DragonflyLogo className="w-24 h-24 object-contain" />
> <DialogTitle className="text-lg">{appName}</DialogTitle>
<DragonflyLogo className="w-24 h-24 object-contain" /> <DialogDescription className="text-xs">
<div className="text-center">
<h2 className="text-lg font-bold" style={{ color: "var(--df-text)" }}>
{appName}
</h2>
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>
v{appVersion} v{appVersion}
</p> </DialogDescription>
</div> </DialogHeader>
<p className="text-xs text-center px-4 leading-relaxed" style={{ color: "var(--df-text-dimmed)" }}> <p className="text-xs text-center px-4 leading-relaxed text-muted-foreground">
{t("about.description")} {t("about.description")}
</p> </p>
<div className="flex gap-3 w-full pt-2"> <div className="flex gap-3 w-full pt-2">
<button <Button
className="flex-1 py-2 text-xs font-medium rounded border transition-colors hover:bg-white/5" variant="outline"
style={{ borderColor: "var(--df-border)", color: "var(--df-text)" }} size="sm"
className="flex-1 text-xs"
onClick={() => openUrl(pkg.homepage)} onClick={() => openUrl(pkg.homepage)}
> >
{t("about.website")} {t("about.website")}
</button> </Button>
<button <Button
className="flex-1 py-2 text-xs font-medium rounded border transition-colors hover:bg-white/5" variant="outline"
style={{ borderColor: "var(--df-border)", color: "var(--df-text)" }} size="sm"
className="flex-1 text-xs"
onClick={() => openUrl(pkg.bugs.url)} onClick={() => openUrl(pkg.bugs.url)}
> >
{t("about.issues")} {t("about.issues")}
</button> </Button>
</div> </div>
<div className="w-full"> <DialogFooter className="w-full">
<button <Button size="sm" className="w-full text-xs" onClick={onClose}>
className="w-full py-2 text-xs font-medium rounded transition-colors hover:opacity-90"
style={{
backgroundColor: "var(--df-primary)",
color: "#fff"
}}
onClick={onClose}
>
{t("about.close")} {t("about.close")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
); );
} }
+45 -66
View File
@@ -1,7 +1,14 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface AutoUploadDialogData { export interface AutoUploadDialogData {
sessionId: string; sessionId: string;
@@ -18,16 +25,6 @@ interface AutoUploadDialogProps {
export default function AutoUploadDialog({ data, onClose, onAlwaysUpload }: AutoUploadDialogProps) { export default function AutoUploadDialog({ data, onClose, onAlwaysUpload }: AutoUploadDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleUpload = (always: boolean) => { const handleUpload = (always: boolean) => {
if (always) { if (always) {
onAlwaysUpload(data.sessionId, data.localPath); onAlwaysUpload(data.sessionId, data.localPath);
@@ -41,63 +38,45 @@ export default function AutoUploadDialog({ data, onClose, onAlwaysUpload }: Auto
onClose(); onClose();
}; };
return createPortal( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity"> <Dialog open onOpenChange={(v) => !v && onClose()}>
<div <DialogContent className="w-96 sm:max-w-96">
className="rounded-lg shadow-2xl p-5 w-96 flex flex-col gap-4" <DialogHeader>
style={{ <div className="flex items-center gap-3">
backgroundColor: "var(--df-bg-panel)", <div
border: "1px solid var(--df-border)", className="flex items-center justify-center w-8 h-8 rounded-full"
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)" style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 15%, transparent)" }}
}} >
> <span className="material-icons text-[18px] text-primary">cloud_sync</span>
<div className="flex items-center gap-3 border-b pb-3" style={{ borderColor: 'var(--df-border)' }}> </div>
<div className="flex items-center justify-center w-8 h-8 rounded-full" style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 15%, transparent)" }}> <DialogTitle className="text-sm font-semibold">
<span className="material-icons text-[18px]" style={{ color: "var(--df-primary)" }}>cloud_sync</span> {t("fileExplorer.fileModified", "File Modified Locally")}
</DialogTitle>
</div> </div>
<h3 className="font-semibold text-sm" style={{ color: "var(--df-text)" }}> <DialogDescription className="text-xs break-all leading-relaxed">
{t("fileExplorer.fileModified", "File Modified Locally")} {t("fileExplorer.uploadPrompt", "The following file was modified. Do you want to upload it to the remote server?")}
</h3> <br /><br />
</div> <span
className="font-mono bg-black/20 px-2 py-1 rounded border inline-block w-full truncate"
<p className="text-xs break-all leading-relaxed" style={{ color: "var(--df-text-muted)" }}> style={{ color: "var(--df-text)", borderColor: "var(--df-border)" }}
{t("fileExplorer.uploadPrompt", "The following file was modified. Do you want to upload it to the remote server?")}<br /><br /> title={data.remotePath}
<span className="font-mono bg-black/20 px-2 py-1 rounded border inline-block w-full truncate" style={{ color: "var(--df-text)", borderColor: "var(--df-border)" }} title={data.remotePath}> >
{data.remotePath} {data.remotePath}
</span> </span>
</p> </DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t" style={{ borderColor: 'var(--df-border)' }}> <DialogFooter>
<button <Button variant="ghost" size="sm" className="text-xs" onClick={onClose}>
className="px-4 py-1.5 rounded transition-colors text-xs font-medium"
style={{ color: "var(--df-text-muted)" }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--df-bg-hover)"; e.currentTarget.style.color = "var(--df-text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; e.currentTarget.style.color = "var(--df-text-muted)"; }}
onClick={onClose}
>
{t("dialog.cancel")} {t("dialog.cancel")}
</button> </Button>
<button <Button variant="outline" size="sm" className="text-xs flex-1" onClick={() => handleUpload(true)}>
className="px-4 py-1.5 rounded transition-colors text-xs font-medium flex-1 text-center"
style={{ backgroundColor: "var(--df-bg-hover)", color: "var(--df-text)", border: "1px solid var(--df-border)" }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--df-border)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--df-bg-hover)"; }}
onClick={() => handleUpload(true)}
>
{t("fileExplorer.alwaysUpload", "Always")} {t("fileExplorer.alwaysUpload", "Always")}
</button> </Button>
<button <Button size="sm" className="text-xs flex-1" onClick={() => handleUpload(false)}>
className="px-4 py-1.5 rounded transition-colors text-xs font-medium flex items-center justify-center gap-1 flex-1"
style={{ backgroundColor: "var(--df-primary)", color: "#fff" }}
onMouseEnter={(e) => { e.currentTarget.style.filter = "brightness(1.1)"; }}
onMouseLeave={(e) => { e.currentTarget.style.filter = "none"; }}
onClick={() => handleUpload(false)}
>
{t("fileExplorer.uploadOnce", "Upload")} {t("fileExplorer.uploadOnce", "Upload")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div>, </Dialog>
document.body
); );
} }
+8 -27
View File
@@ -18,7 +18,6 @@ export default function GlobalContextMenu() {
} }
}; };
// Use capture phase so we close it *before* other clicks register
if (contextMenu) { if (contextMenu) {
window.addEventListener("click", handleClick, true); window.addEventListener("click", handleClick, true);
window.addEventListener("contextmenu", handleClick, true); window.addEventListener("contextmenu", handleClick, true);
@@ -34,11 +33,10 @@ export default function GlobalContextMenu() {
if (!contextMenu) return null; if (!contextMenu) return null;
// Simple bounds checking so the menu doesn't overflow screen edges
const maxW = window.innerWidth; const maxW = window.innerWidth;
const maxH = window.innerHeight; const maxH = window.innerHeight;
const menuWidth = 200; // approximate const menuWidth = 200;
const menuHeight = contextMenu.items.length * 36; // approximate per item const menuHeight = contextMenu.items.length * 36;
let { x, y } = contextMenu; let { x, y } = contextMenu;
if (x + menuWidth > maxW) x = maxW - menuWidth - 10; if (x + menuWidth > maxW) x = maxW - menuWidth - 10;
@@ -47,44 +45,27 @@ export default function GlobalContextMenu() {
return ( return (
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-[10000] py-1 rounded shadow-2xl border text-xs" className="bg-popover text-popover-foreground fixed z-[10000] min-w-[180px] overflow-hidden rounded-md border p-1 shadow-md animate-in fade-in-0 zoom-in-95"
style={{ style={{ left: x, top: y }}
left: x,
top: y,
backgroundColor: "var(--df-bg-panel)",
borderColor: "var(--df-border)",
color: "var(--df-text)",
minWidth: "180px",
}}
// Prevent propagating clicks inside the menu to avoid immediately closing it,
// though our custom capture event handler handles outside clicks already.
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{contextMenu.items.map((item, idx) => { {contextMenu.items.map((item, idx) => {
if (item.divider) { if (item.divider) {
return ( return <div key={`div-${idx}`} className="bg-border -mx-1 my-1 h-px" />;
<div
key={`div-${idx}`}
className="border-b my-1"
style={{ borderColor: "var(--df-border)" }}
/>
);
} }
return ( return (
<div <div
key={`item-${idx}`} key={`item-${idx}`}
className="px-3 py-1.5 cursor-pointer flex items-center gap-2 transition-colors" className="relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none transition-colors hover:bg-accent hover:text-accent-foreground"
style={{ color: item.color || "inherit" }} style={{ color: item.color || undefined }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--df-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
hideContextMenu(); hideContextMenu();
item.onClick(); item.onClick();
}} }}
> >
{item.icon && <span className="material-icons text-[14px]">{item.icon}</span>} {item.icon && <span className="material-icons text-[14px] text-muted-foreground">{item.icon}</span>}
{item.label} {item.label}
</div> </div>
); );
+35 -44
View File
@@ -1,7 +1,16 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export interface MoveDialogData { export interface MoveDialogData {
sessionId: string; sessionId: string;
@@ -24,16 +33,6 @@ export default function MoveDialog({ data, onClose, onSuccess }: MoveDialogProps
setDialogInput(data.oldPath); setDialogInput(data.oldPath);
}, [data.oldPath]); }, [data.oldPath]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, isSubmitting]);
const handleMoveSubmit = async () => { const handleMoveSubmit = async () => {
if (!dialogInput || dialogInput === data.oldPath) { if (!dialogInput || dialogInput === data.oldPath) {
onClose(); onClose();
@@ -56,41 +55,33 @@ export default function MoveDialog({ data, onClose, onSuccess }: MoveDialogProps
} }
}; };
return createPortal( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"> <Dialog open onOpenChange={(v) => !v && !isSubmitting && onClose()}>
<div className="rounded shadow-xl p-4 w-96" style={{ backgroundColor: "var(--df-bg-panel)", border: "1px solid var(--df-border)" }}> <DialogContent className="w-96 sm:max-w-96">
<h3 className="font-bold mb-4">{t("fileExplorer.moveTo", { name: data.name })}</h3> <DialogHeader>
<input <DialogTitle className="text-sm">{t("fileExplorer.moveTo", { name: data.name })}</DialogTitle>
type="text" </DialogHeader>
className="w-full text-sm p-2 rounded border mb-4 focus:outline-none" <div className="space-y-2">
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} <Label className="text-xs">{t("fileExplorer.moveTo", { name: data.name })}</Label>
value={dialogInput} <Input
onChange={(e) => setDialogInput(e.target.value)} className="text-sm"
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleMoveSubmit()} value={dialogInput}
disabled={isSubmitting} onChange={(e) => setDialogInput(e.target.value)}
autoFocus onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleMoveSubmit()}
/>
<div className="flex justify-end gap-2">
<button
className="px-3 py-1 rounded text-sm hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--df-bg-hover)" }}
onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
{t("dialog.cancel")} {t("dialog.cancel")}
</button> </Button>
<button <Button size="sm" onClick={handleMoveSubmit} disabled={isSubmitting}>
className="px-3 py-1 rounded text-sm hover:opacity-80 transition-opacity flex items-center gap-1"
style={{ backgroundColor: "var(--df-primary)", color: "#fff", opacity: isSubmitting ? 0.5 : 1 }}
onClick={handleMoveSubmit}
disabled={isSubmitting}
>
{isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>} {isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>}
{t("dialog.save")} {t("dialog.save")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div>, </Dialog>
document.body
); );
} }
+97 -225
View File
@@ -3,6 +3,17 @@ import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Group, SavedConnection } from "../../types"; import type { Group, SavedConnection } from "../../types";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface NewSessionDialogProps { interface NewSessionDialogProps {
open: boolean; open: boolean;
@@ -12,13 +23,6 @@ interface NewSessionDialogProps {
initialData?: SavedConnection; initialData?: SavedConnection;
} }
/* Shared input style using CSS variables */
const inputStyle: React.CSSProperties = {
backgroundColor: "var(--df-bg-input)",
borderColor: "var(--df-border)",
color: "var(--df-text)",
};
/** Modal for new/edit SSH connection or local terminal. Save, connect, or cancel. */ /** Modal for new/edit SSH connection or local terminal. Save, connect, or cancel. */
export default function NewSessionDialog({ export default function NewSessionDialog({
open, open,
@@ -108,18 +112,8 @@ export default function NewSessionDialog({
} }
}, [open, initialData, resetForm]); }, [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 = () => { const handleClose = () => {
if (connecting) return;
resetForm(); resetForm();
onClose(); onClose();
}; };
@@ -180,116 +174,68 @@ export default function NewSessionDialog({
} }
}; };
if (!open) return null;
const inputClass = "w-full rounded px-3 py-2 text-xs focus:outline-none border transition-colors";
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<div <DialogContent className="w-[480px] sm:max-w-[480px] p-0 gap-0">
className="rounded-lg w-[480px] shadow-2xl border" <DialogHeader className="px-5 py-3 border-b">
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }} <DialogTitle className="text-sm">
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3 border-b"
style={{ borderColor: "var(--df-border)" }}
>
<h2 className="text-sm font-semibold" style={{ color: "var(--df-text)" }}>
{initialData ? t("dialog.editConnection") : t("dialog.newConnection")} {initialData ? t("dialog.editConnection") : t("dialog.newConnection")}
</h2> </DialogTitle>
<span </DialogHeader>
className="material-icons text-base cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--df-text-muted)" }}
onClick={handleClose}
>
close
</span>
</div>
{/* Body */} {/* Body */}
<div className="p-5 space-y-4"> <div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
{!initialData && ( {!initialData && (
<button <Button
className="w-full flex items-center gap-3 p-3 border rounded transition-colors text-left hover:opacity-90" variant="outline"
style={{ backgroundColor: "var(--df-bg-hover)", borderColor: "var(--df-border)" }} className="w-full flex items-center gap-3 p-3 h-auto text-left justify-start"
onClick={handleConnectLocal} onClick={handleConnectLocal}
disabled={connecting} disabled={connecting}
> >
<span className="material-icons text-xl text-cyan-400">terminal</span> <span className="material-icons text-xl text-cyan-400">terminal</span>
<div> <div>
<div className="text-xs font-medium" style={{ color: "var(--df-text)" }}> <div className="text-xs font-medium">{t("dialog.localTerminal")}</div>
{t("dialog.localTerminal")} <div className="text-[10px] text-muted-foreground">{t("dialog.openLocalShell")}</div>
</div>
<div className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
{t("dialog.openLocalShell")}
</div>
</div> </div>
</button> </Button>
)} )}
{!initialData && ( {!initialData && (
<div <div className="flex items-center gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
className="flex items-center gap-3 text-[10px] uppercase tracking-wider" <div className="flex-1 border-t" />
style={{ color: "var(--df-text-dimmed)" }}
>
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div>
<span>{t("dialog.sshConnection")}</span> <span>{t("dialog.sshConnection")}</span>
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div> <div className="flex-1 border-t" />
</div> </div>
)} )}
{/* Name + Group */} {/* Name + Group */}
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1"> <div className="flex-1">
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.connectionName")}</Label>
{t("dialog.connectionName")} <Input
</label> className="mt-1 text-xs h-8"
<input
type="text"
placeholder={t("dialog.serverPlaceholder")} placeholder={t("dialog.serverPlaceholder")}
className={inputClass}
style={inputStyle}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="w-52 relative" ref={groupRef}> <div className="w-52 relative" ref={groupRef}>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.group")}</Label>
{t("dialog.group")} <Button
</label>
<button
type="button" type="button"
className="w-full flex items-center justify-between rounded px-3 py-2 text-xs text-left border transition-colors" variant="outline"
style={inputStyle} className="w-full mt-1 h-8 justify-between text-xs font-normal"
onClick={() => setShowGroupDropdown(!showGroupDropdown)} onClick={() => setShowGroupDropdown(!showGroupDropdown)}
> >
<span style={{ color: group ? "var(--df-text)" : "var(--df-text-dimmed)" }}> <span className={group ? "" : "text-muted-foreground"}>
{group || t("dialog.none")} {group || t("dialog.none")}
</span> </span>
<span className="material-icons text-xs" style={{ color: "var(--df-text-dimmed)" }}> <span className="material-icons text-xs text-muted-foreground">expand_more</span>
expand_more </Button>
</span>
</button>
{showGroupDropdown && ( {showGroupDropdown && (
<div <div className="absolute top-full left-0 right-0 mt-1 border rounded-md shadow-xl z-10 overflow-hidden bg-popover">
className="absolute top-full left-0 right-0 mt-1 border rounded shadow-xl z-10 overflow-hidden"
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
>
<div <div
className="px-3 py-1.5 text-xs cursor-pointer transition-colors" className={`px-3 py-1.5 text-xs cursor-pointer transition-colors hover:bg-accent ${!group ? "bg-primary/15 text-primary" : "text-muted-foreground"}`}
style={{
backgroundColor: !group
? "color-mix(in srgb, var(--df-primary) 15%, transparent)"
: undefined,
color: !group ? "var(--df-primary)" : "var(--df-text-muted)",
}}
onMouseEnter={(e) => {
if (group) e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
}}
onMouseLeave={(e) => {
if (group) e.currentTarget.style.backgroundColor = "";
}}
onClick={() => { onClick={() => {
setGroup(""); setGroup("");
setShowGroupDropdown(false); setShowGroupDropdown(false);
@@ -300,21 +246,7 @@ export default function NewSessionDialog({
{groups.map((g) => ( {groups.map((g) => (
<div <div
key={g.id} key={g.id}
className="px-3 py-1.5 text-xs cursor-pointer transition-colors" className={`px-3 py-1.5 text-xs cursor-pointer transition-colors hover:bg-accent ${group === g.name ? "bg-primary/15 text-primary" : ""}`}
style={{
backgroundColor:
group === g.name
? "color-mix(in srgb, var(--df-primary) 15%, transparent)"
: undefined,
color: group === g.name ? "var(--df-primary)" : "var(--df-text)",
}}
onMouseEnter={(e) => {
if (group !== g.name)
e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
}}
onMouseLeave={(e) => {
if (group !== g.name) e.currentTarget.style.backgroundColor = "";
}}
onClick={() => { onClick={() => {
setGroup(g.name); setGroup(g.name);
setShowGroupDropdown(false); setShowGroupDropdown(false);
@@ -323,16 +255,11 @@ export default function NewSessionDialog({
{g.name} {g.name}
</div> </div>
))} ))}
<div <div className="p-1.5 border-t">
className="p-1.5 border-t"
style={{ borderColor: "color-mix(in srgb, var(--df-border) 60%, transparent)" }}
>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <Input
type="text" className="flex-1 min-w-0 h-7 text-xs"
placeholder={t("dialog.newGroupPlaceholder")} placeholder={t("dialog.newGroupPlaceholder")}
className="flex-1 min-w-0 rounded px-2 py-1 text-xs border focus:outline-none"
style={inputStyle}
value={newGroupName} value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)} onChange={(e) => setNewGroupName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -343,9 +270,9 @@ export default function NewSessionDialog({
} }
}} }}
/> />
<button <Button
className="flex-shrink-0 p-1 transition-colors disabled:opacity-30" variant="ghost"
style={{ color: "var(--df-text-muted)" }} size="icon-xs"
disabled={!newGroupName.trim()} disabled={!newGroupName.trim()}
onClick={() => { onClick={() => {
if (newGroupName.trim()) { if (newGroupName.trim()) {
@@ -356,7 +283,7 @@ export default function NewSessionDialog({
}} }}
> >
<span className="material-icons text-sm">add</span> <span className="material-icons text-sm">add</span>
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -367,26 +294,19 @@ export default function NewSessionDialog({
{/* Host + Port */} {/* Host + Port */}
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1"> <div className="flex-1">
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.host")}</Label>
{t("dialog.host")} <Input
</label> className="mt-1 text-xs h-8"
<input
type="text"
placeholder="192.168.1.100" placeholder="192.168.1.100"
className={inputClass}
style={inputStyle}
value={host} value={host}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
/> />
</div> </div>
<div className="w-20"> <div className="w-20">
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.port")}</Label>
{t("dialog.port")} <Input
</label>
<input
type="number" type="number"
className={inputClass} className="mt-1 text-xs h-8"
style={inputStyle}
value={port} value={port}
onChange={(e) => setPort(Number(e.target.value))} onChange={(e) => setPort(Number(e.target.value))}
/> />
@@ -395,13 +315,9 @@ export default function NewSessionDialog({
{/* Username */} {/* Username */}
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.username")}</Label>
{t("dialog.username")} <Input
</label> className="mt-1 text-xs h-8"
<input
type="text"
className={inputClass}
style={inputStyle}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
@@ -409,51 +325,34 @@ export default function NewSessionDialog({
{/* Auth Type */} {/* Auth Type */}
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.authentication")}</Label>
{t("dialog.authentication")} <div className="flex gap-2 mt-1">
</label> <Button
<div className="flex gap-2"> variant={authType === "password" ? "default" : "outline"}
<button size="sm"
className="flex-1 py-1.5 text-xs rounded border transition-colors" className="flex-1 text-xs"
style={{
backgroundColor:
authType === "password"
? "color-mix(in srgb, var(--df-primary) 20%, transparent)"
: "var(--df-bg-input)",
borderColor: authType === "password" ? "var(--df-primary)" : "var(--df-border)",
color: authType === "password" ? "var(--df-primary)" : "var(--df-text-muted)",
}}
onClick={() => setAuthType("password")} onClick={() => setAuthType("password")}
> >
{t("dialog.password")} {t("dialog.password")}
</button> </Button>
<button <Button
className="flex-1 py-1.5 text-xs rounded border transition-colors" variant={authType === "key" ? "default" : "outline"}
style={{ size="sm"
backgroundColor: className="flex-1 text-xs"
authType === "key"
? "color-mix(in srgb, var(--df-primary) 20%, transparent)"
: "var(--df-bg-input)",
borderColor: authType === "key" ? "var(--df-primary)" : "var(--df-border)",
color: authType === "key" ? "var(--df-primary)" : "var(--df-text-muted)",
}}
onClick={() => setAuthType("key")} onClick={() => setAuthType("key")}
> >
{t("dialog.privateKey")} {t("dialog.privateKey")}
</button> </Button>
</div> </div>
</div> </div>
{/* Password or Key File */} {/* Password or Key File */}
{authType === "password" ? ( {authType === "password" ? (
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.password")}</Label>
{t("dialog.password")} <Input
</label>
<input
type="password" type="password"
className={inputClass} className="mt-1 text-xs h-8"
style={inputStyle}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
@@ -461,32 +360,18 @@ export default function NewSessionDialog({
) : ( ) : (
<> <>
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.privateKey")}</Label>
{t("dialog.privateKey")} <div className="flex items-center w-full rounded-md border overflow-hidden mt-1 bg-transparent">
</label>
<div
className="flex items-center w-full rounded border overflow-hidden transition-colors"
style={{
backgroundColor: "var(--df-bg-input)",
borderColor: "var(--df-border)",
}}
>
<div <div
className="flex-1 px-3 py-2 text-xs truncate" className={`flex-1 px-3 py-2 text-xs truncate ${keyFileName || hasKeyData ? "text-foreground" : "text-muted-foreground opacity-50"}`}
style={{
color: keyFileName || hasKeyData ? "var(--df-text)" : "var(--df-text-muted)",
opacity: keyFileName || hasKeyData ? 1 : 0.5,
}}
> >
{keyFileName || (hasKeyData ? t("dialog.keyFileLoaded") : t("dialog.selectKeyFile"))} {keyFileName || (hasKeyData ? t("dialog.keyFileLoaded") : t("dialog.selectKeyFile"))}
</div> </div>
<button <Button
type="button" type="button"
className="px-3 py-2 hover:bg-black/10 dark:hover:bg-white/10 transition-colors flex items-center justify-center border-l" variant="ghost"
style={{ size="sm"
borderColor: "var(--df-border)", className="rounded-none border-l h-auto py-2"
color: "var(--df-text-muted)",
}}
onClick={async () => { onClick={async () => {
const selected = await openFileDialog({ const selected = await openFileDialog({
multiple: false, multiple: false,
@@ -501,17 +386,14 @@ export default function NewSessionDialog({
}} }}
> >
<span className="material-icons text-sm">folder_open</span> <span className="material-icons text-sm">folder_open</span>
</button> </Button>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.passphrase")}</Label>
{t("dialog.passphrase")} <Input
</label>
<input
type="password" type="password"
className={inputClass} className="mt-1 text-xs h-8"
style={inputStyle}
value={passphrase} value={passphrase}
onChange={(e) => setPassphrase(e.target.value)} onChange={(e) => setPassphrase(e.target.value)}
/> />
@@ -521,14 +403,11 @@ export default function NewSessionDialog({
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}> <Label className="text-[11px] text-muted-foreground">{t("dialog.description")}</Label>
{t("dialog.description")} <Textarea
</label>
<textarea
rows={2} rows={2}
placeholder={t("dialog.descriptionPlaceholder")} placeholder={t("dialog.descriptionPlaceholder")}
className="w-full rounded px-3 py-2 text-xs border focus:outline-none resize-none transition-colors" className="mt-1 text-xs resize-none"
style={inputStyle}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
@@ -536,7 +415,7 @@ export default function NewSessionDialog({
{/* Messages */} {/* Messages */}
{error && ( {error && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-xs text-red-400"> <div className="p-2 bg-destructive/10 border border-destructive/30 rounded text-xs text-red-400">
{error} {error}
</div> </div>
)} )}
@@ -548,27 +427,20 @@ export default function NewSessionDialog({
</div> </div>
{/* Footer */} {/* Footer */}
<div <DialogFooter className="px-5 py-3 border-t">
className="flex justify-end gap-2 px-5 py-3 border-t" <Button variant="ghost" size="sm" className="text-xs" onClick={handleClose}>
style={{ borderColor: "var(--df-border)" }}
>
<button
className="px-4 py-1.5 text-xs transition-colors hover:opacity-70"
style={{ color: "var(--df-text-muted)" }}
onClick={handleClose}
>
{t("dialog.cancel")} {t("dialog.cancel")}
</button> </Button>
<button <Button
className="px-4 py-1.5 text-xs text-white rounded transition-colors disabled:opacity-50" size="sm"
style={{ backgroundColor: "var(--df-primary)" }} className="text-xs"
onClick={handleSave} onClick={handleSave}
disabled={connecting || !host} disabled={connecting || !host}
> >
{connecting ? t("dialog.saving") : t("dialog.save")} {connecting ? t("dialog.saving") : t("dialog.save")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
); );
} }
+74 -128
View File
@@ -1,7 +1,15 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface PropertiesDialogData { export interface PropertiesDialogData {
sessionId: string; sessionId: string;
@@ -26,12 +34,6 @@ interface PropertiesDialogProps {
onClose: () => void; onClose: () => void;
} }
const inputStyle: React.CSSProperties = {
backgroundColor: "var(--df-bg-input)",
borderColor: "var(--df-border)",
color: "var(--df-text)",
};
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
if (bytes < 1024) return `${bytes} Bytes`; if (bytes < 1024) return `${bytes} Bytes`;
@@ -101,16 +103,6 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [data.sessionId, data.fullPath]); }, [data.sessionId, data.fullPath]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSaving) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, isSaving]);
const handleSave = async () => { const handleSave = async () => {
if (octal === initialOctal) { if (octal === initialOctal) {
onClose(); onClose();
@@ -162,94 +154,67 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
return data.fullPath.substring(0, idx + 1); return data.fullPath.substring(0, idx + 1);
}; };
return createPortal( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity"> <Dialog open onOpenChange={(v) => !v && !isSaving && onClose()}>
<div <DialogContent className="w-[420px] sm:max-w-[420px] p-0 gap-0">
className="rounded-lg w-[420px] shadow-2xl border flex flex-col"
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
>
{/* Header */} {/* Header */}
<div <DialogHeader className="px-5 py-3 border-b">
className="flex items-center justify-between px-5 py-3 border-b select-none" <DialogTitle className="text-sm flex items-center gap-2">
style={{ borderColor: "var(--df-border)" }}
>
<div className="flex items-center gap-2">
<span className="material-icons text-lg" style={{ color: data.is_dir ? "#eab308" : "var(--df-primary)" }}> <span className="material-icons text-lg" style={{ color: data.is_dir ? "#eab308" : "var(--df-primary)" }}>
{data.is_dir ? 'folder' : 'insert_drive_file'} {data.is_dir ? 'folder' : 'insert_drive_file'}
</span> </span>
<h2 className="text-sm font-semibold truncate max-w-[300px]" style={{ color: "var(--df-text)" }} title={data.name}> <span className="truncate max-w-[300px]" title={data.name}>
{t("fileExplorer.propertiesOf", { name: data.name })} {t("fileExplorer.propertiesOf", { name: data.name })}
</h2> </span>
</div> </DialogTitle>
<span </DialogHeader>
className="material-icons text-base cursor-pointer transition-opacity hover:opacity-70"
style={{ color: "var(--df-text-muted)" }}
onClick={onClose}
>
close
</span>
</div>
{/* Body */} {/* Body */}
<div className="p-5 overflow-y-auto max-h-[75vh] terminal-scroll space-y-5 relative min-h-[250px]"> <div className="p-5 overflow-y-auto max-h-[75vh] space-y-5 relative min-h-[250px]">
{loading ? ( {loading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-2 text-[var(--df-text-muted)]"> <div className="absolute inset-0 flex flex-col items-center justify-center space-y-2 text-muted-foreground">
<span className="material-icons animate-spin text-3xl">refresh</span> <span className="material-icons animate-spin text-3xl">refresh</span>
<span className="text-xs">{t("fileExplorer.loading", "Loading...")}</span> <span className="text-xs">{t("fileExplorer.loading", "Loading...")}</span>
</div> </div>
) : error ? ( ) : error ? (
<div className="absolute inset-0 flex items-center justify-center text-red-500 text-xs px-5 text-center"> <div className="absolute inset-0 flex items-center justify-center text-destructive text-xs px-5 text-center">
{error} {error}
</div> </div>
) : properties ? ( ) : properties ? (
<> <>
{/* General Information Section */} {/* General Information */}
<div> <div>
<h3 className="text-xs font-semibold mb-3 tracking-wider uppercase" style={{ color: "var(--df-text-muted)" }}> <h3 className="text-xs font-semibold mb-3 tracking-wider uppercase text-muted-foreground">
{t("fileExplorer.general", "General")} {t("fileExplorer.general", "General")}
</h3> </h3>
<div className="space-y-2.5 text-xs text-left" style={{ color: "var(--df-text)" }}> <div className="space-y-2.5 text-xs text-left">
<div className="flex items-start"> {[
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.type", "Type")}:</span> [t("fileExplorer.type", "Type"), getFileType()],
<span>{getFileType()}</span> [t("fileExplorer.location", "Location"), <span key="loc" className="truncate break-all select-all font-mono" title={getLocation()}>{getLocation()}</span>],
</div> [t("fileExplorer.size", "Size"), formatSize(properties.size)],
<div className="flex items-start"> [t("fileExplorer.mtime", "Modified"), <span key="mt" className="font-mono">{formatTime(properties.mtime)}</span>],
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.location", "Location")}:</span> [t("fileExplorer.atime", "Accessed"), <span key="at" className="font-mono">{formatTime(properties.atime)}</span>],
<span className="truncate break-all select-all font-mono" title={getLocation()}>{getLocation()}</span> [t("fileExplorer.owner", "Owner"), <span key="ow">{properties.owner} <span className="font-mono opacity-70">[{properties.uid}]</span></span>],
</div> [t("fileExplorer.group", "Group"), <span key="gr">{properties.group} <span className="font-mono opacity-70">[{properties.gid}]</span></span>],
<div className="flex items-start"> ].map(([label, value], i) => (
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.size", "Size")}:</span> <div key={i} className="flex items-start">
<span>{formatSize(properties.size)}</span> <span className="w-24 shrink-0 text-muted-foreground">{label}:</span>
</div> <span>{value}</span>
<div className="flex items-start"> </div>
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.mtime", "Modified")}:</span> ))}
<span className="font-mono">{formatTime(properties.mtime)}</span>
</div>
<div className="flex items-start">
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.atime", "Accessed")}:</span>
<span className="font-mono">{formatTime(properties.atime)}</span>
</div>
<div className="flex items-start">
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.owner", "Owner")}:</span>
<span>{properties.owner} <span className="font-mono opacity-70">[{properties.uid}]</span></span>
</div>
<div className="flex items-start">
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.group", "Group")}:</span>
<span>{properties.group} <span className="font-mono opacity-70">[{properties.gid}]</span></span>
</div>
</div> </div>
</div> </div>
<div className="border-t" style={{ borderColor: "var(--df-border)" }} /> <div className="border-t" />
{/* Permissions Section */} {/* Permissions */}
<div> <div>
<h3 className="text-xs font-semibold mb-3 tracking-wider uppercase" style={{ color: "var(--df-text-muted)" }}> <h3 className="text-xs font-semibold mb-3 tracking-wider uppercase text-muted-foreground">
{t("fileExplorer.permissions", "Permissions")} {t("fileExplorer.permissions", "Permissions")}
</h3> </h3>
<div className="rounded border overflow-hidden" style={{ borderColor: "var(--df-border)", backgroundColor: "var(--df-bg)" }}> <div className="rounded-md border overflow-hidden bg-background">
<table className="w-full text-xs text-left select-none"> <table className="w-full text-xs text-left select-none">
<thead style={{ backgroundColor: "color-mix(in srgb, var(--df-border) 20%, transparent)", color: "var(--df-text-muted)" }}> <thead className="bg-muted text-muted-foreground">
<tr> <tr>
<th className="font-normal px-3 py-2 w-16"></th> <th className="font-normal px-3 py-2 w-16"></th>
<th className="font-normal px-2 py-2 text-center w-14">R</th> <th className="font-normal px-2 py-2 text-center w-14">R</th>
@@ -258,45 +223,40 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
<th className="font-normal px-2 py-2 text-center">{t("fileExplorer.special", "Special")}</th> <th className="font-normal px-2 py-2 text-center">{t("fileExplorer.special", "Special")}</th>
</tr> </tr>
</thead> </thead>
<tbody style={{ color: "var(--df-text)" }}> <tbody>
<tr className="border-t" style={{ borderColor: "var(--df-border)", backgroundColor: "color-mix(in srgb, var(--df-bg-input) 50%, transparent)" }}> {[
<td className="px-3 py-2" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.permUser", "User")}</td> { label: t("fileExplorer.permUser", "User"), idx: 1, sIdx: 0, sBit: 4, sLabel: "UID", alt: true },
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(1, 4)} onChange={(e) => updateBit(1, 4, e.target.checked)} /></td> { label: t("fileExplorer.permGroup", "Group"), idx: 2, sIdx: 0, sBit: 2, sLabel: "GID", alt: false },
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(1, 2)} onChange={(e) => updateBit(1, 2, e.target.checked)} /></td> { label: t("fileExplorer.permOther", "Other"), idx: 3, sIdx: 0, sBit: 1, sLabel: t("fileExplorer.permSticky", "Sticky"), alt: true },
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(1, 1)} onChange={(e) => updateBit(1, 1, e.target.checked)} /></td> ].map((row) => (
<td className="px-2 py-2 text-center"><label className="flex items-center justify-center gap-1.5 cursor-pointer text-[10px]"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(0, 4)} onChange={(e) => updateBit(0, 4, e.target.checked)} /> UID</label></td> <tr key={row.idx} className={`border-t ${row.alt ? "bg-muted/30" : ""}`}>
</tr> <td className="px-3 py-2 text-muted-foreground">{row.label}</td>
<tr className="border-t" style={{ borderColor: "var(--df-border)" }}> <td className="px-2 py-2 text-center"><input type="checkbox" className="accent-primary cursor-pointer" checked={hasBit(row.idx, 4)} onChange={(e) => updateBit(row.idx, 4, e.target.checked)} /></td>
<td className="px-3 py-2" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.permGroup", "Group")}</td> <td className="px-2 py-2 text-center"><input type="checkbox" className="accent-primary cursor-pointer" checked={hasBit(row.idx, 2)} onChange={(e) => updateBit(row.idx, 2, e.target.checked)} /></td>
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(2, 4)} onChange={(e) => updateBit(2, 4, e.target.checked)} /></td> <td className="px-2 py-2 text-center"><input type="checkbox" className="accent-primary cursor-pointer" checked={hasBit(row.idx, 1)} onChange={(e) => updateBit(row.idx, 1, e.target.checked)} /></td>
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(2, 2)} onChange={(e) => updateBit(2, 2, e.target.checked)} /></td> <td className="px-2 py-2 text-center">
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(2, 1)} onChange={(e) => updateBit(2, 1, e.target.checked)} /></td> <label className="flex items-center justify-center gap-1.5 cursor-pointer text-[10px]">
<td className="px-2 py-2 text-center"><label className="flex items-center justify-center gap-1.5 cursor-pointer text-[10px]"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(0, 2)} onChange={(e) => updateBit(0, 2, e.target.checked)} /> GID</label></td> <input type="checkbox" className="accent-primary cursor-pointer" checked={hasBit(row.sIdx, row.sBit)} onChange={(e) => updateBit(row.sIdx, row.sBit, e.target.checked)} />
</tr> {row.sLabel}
<tr className="border-t" style={{ borderColor: "var(--df-border)", backgroundColor: "color-mix(in srgb, var(--df-bg-input) 50%, transparent)" }}> </label>
<td className="px-3 py-2" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.permOther", "Other")}</td> </td>
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(3, 4)} onChange={(e) => updateBit(3, 4, e.target.checked)} /></td> </tr>
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(3, 2)} onChange={(e) => updateBit(3, 2, e.target.checked)} /></td> ))}
<td className="px-2 py-2 text-center"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(3, 1)} onChange={(e) => updateBit(3, 1, e.target.checked)} /></td>
<td className="px-2 py-2 text-center"><label className="flex items-center justify-center gap-1.5 cursor-pointer text-[10px]"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(0, 1)} onChange={(e) => updateBit(0, 1, e.target.checked)} /> {t("fileExplorer.permSticky", "Sticky")}</label></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
<span className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.octal", "Octal Mode")}:</span> <span className="text-xs text-muted-foreground">{t("fileExplorer.octal", "Octal Mode")}:</span>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-xs font-mono mr-2 opacity-50">0</span> <span className="text-xs font-mono mr-2 opacity-50">0</span>
<input <Input
type="text" className="w-[60px] text-center font-mono text-xs h-7"
className="w-[60px] rounded px-2 py-1 text-center font-mono text-xs border focus:outline-none transition-colors" style={{ letterSpacing: '2px' }}
style={{ ...inputStyle, letterSpacing: '2px' }}
value={octal.substring(1)} value={octal.substring(1)}
onChange={(e) => { onChange={(e) => {
let val = e.target.value.replace(/[^0-7]/g, ''); let val = e.target.value.replace(/[^0-7]/g, '');
if (val.length > 3) val = val.substring(0, 3); if (val.length > 3) val = val.substring(0, 3);
// Keeping Special bit intact when typing the 3 digits
setOctal(octal[0] + val.padStart(3, '0')); setOctal(octal[0] + val.padStart(3, '0'));
}} }}
/> />
@@ -308,30 +268,16 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
</div> </div>
{/* Footer */} {/* Footer */}
<div <DialogFooter className="px-5 py-3 border-t">
className="flex justify-end gap-2 px-5 py-3 border-t bg-black/5" <Button variant="outline" size="sm" className="text-xs" onClick={onClose} disabled={isSaving}>
style={{ borderColor: "var(--df-border)", borderRadius: "0 0 8px 8px" }}
>
<button
className="px-4 py-1.5 text-xs rounded transition-colors hover:opacity-70 border bg-transparent"
style={{ color: "var(--df-text-muted)", borderColor: "var(--df-border)" }}
onClick={onClose}
disabled={isSaving}
>
{t("dialog.cancel", "Cancel")} {t("dialog.cancel", "Cancel")}
</button> </Button>
<button <Button size="sm" className="text-xs" onClick={handleSave} disabled={isSaving || loading || !!error}>
className="px-4 py-1.5 text-xs text-white rounded transition-colors disabled:opacity-50 flex items-center gap-1.5"
style={{ backgroundColor: "var(--df-primary)" }}
onClick={handleSave}
disabled={isSaving || loading || !!error}
>
{isSaving && <span className="material-icons text-[14px] animate-spin">refresh</span>} {isSaving && <span className="material-icons text-[14px] animate-spin">refresh</span>}
{t("dialog.save", "Save Changes")} {t("dialog.save", "Save Changes")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div>, </Dialog>
document.body
); );
} }
+35 -44
View File
@@ -1,7 +1,16 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export interface RenameDialogData { export interface RenameDialogData {
sessionId: string; sessionId: string;
@@ -25,16 +34,6 @@ export default function RenameDialog({ data, onClose, onSuccess }: RenameDialogP
setDialogInput(data.name); setDialogInput(data.name);
}, [data.name]); }, [data.name]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, isSubmitting]);
const handleRenameSubmit = async () => { const handleRenameSubmit = async () => {
if (!dialogInput || dialogInput === data.name) { if (!dialogInput || dialogInput === data.name) {
onClose(); onClose();
@@ -58,41 +57,33 @@ export default function RenameDialog({ data, onClose, onSuccess }: RenameDialogP
} }
}; };
return createPortal( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"> <Dialog open onOpenChange={(v) => !v && !isSubmitting && onClose()}>
<div className="rounded shadow-xl p-4 w-80" style={{ backgroundColor: "var(--df-bg-panel)", border: "1px solid var(--df-border)" }}> <DialogContent className="w-80 sm:max-w-80">
<h3 className="font-bold mb-4">{t("fileExplorer.renameTo", { name: data.name })}</h3> <DialogHeader>
<input <DialogTitle className="text-sm">{t("fileExplorer.renameTo", { name: data.name })}</DialogTitle>
type="text" </DialogHeader>
className="w-full text-sm p-2 rounded border mb-4 focus:outline-none" <div className="space-y-2">
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} <Label className="text-xs">{t("fileExplorer.renameTo", { name: data.name })}</Label>
value={dialogInput} <Input
onChange={(e) => setDialogInput(e.target.value)} className="text-sm"
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleRenameSubmit()} value={dialogInput}
disabled={isSubmitting} onChange={(e) => setDialogInput(e.target.value)}
autoFocus onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleRenameSubmit()}
/>
<div className="flex justify-end gap-2">
<button
className="px-3 py-1 rounded text-sm hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--df-bg-hover)" }}
onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
{t("dialog.cancel")} {t("dialog.cancel")}
</button> </Button>
<button <Button size="sm" onClick={handleRenameSubmit} disabled={isSubmitting}>
className="px-3 py-1 rounded text-sm hover:opacity-80 transition-opacity flex items-center gap-1"
style={{ backgroundColor: "var(--df-primary)", color: "#fff", opacity: isSubmitting ? 0.5 : 1 }}
onClick={handleRenameSubmit}
disabled={isSubmitting}
>
{isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>} {isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>}
{t("dialog.save")} {t("dialog.save")}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div>, </Dialog>
document.body
); );
} }
+327 -435
View File
@@ -4,6 +4,76 @@ import { invoke } from "@tauri-apps/api/core";
import { useApp } from "../../context/AppContext"; import { useApp } from "../../context/AppContext";
import { themeList } from "../../themes"; import { themeList } from "../../themes";
import { AVAILABLE_LANGUAGES } from "../../i18n"; import { AVAILABLE_LANGUAGES } from "../../i18n";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/* ── Reusable sub-components ─────────────────────────────────────────── */
function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<Label className="font-medium text-sm">{label}</Label>
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
</div>
{children}
</div>
);
}
function SettingInput({ label, desc, ...inputProps }: { label: string; desc?: string } & React.ComponentProps<typeof Input>) {
return (
<div className="space-y-1">
<Label className="font-medium text-sm">{label}</Label>
{desc && <p className="text-xs text-muted-foreground">{desc}</p>}
<Input className="text-sm" {...inputProps} />
</div>
);
}
function SettingSelect({ label, desc, value, onValueChange, children }: {
label: string; desc?: string; value: string; onValueChange: (v: string) => void; children: React.ReactNode
}) {
return (
<div className="space-y-1">
<Label className="font-medium text-sm">{label}</Label>
{desc && <p className="text-xs text-muted-foreground pb-1">{desc}</p>}
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{children}
</SelectContent>
</Select>
</div>
);
}
function SettingCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<input
type="checkbox"
className="w-4 h-4 rounded accent-primary cursor-pointer shrink-0"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
);
}
/* ── Main Component ──────────────────────────────────────────────────── */
export default function SettingsDialog() { export default function SettingsDialog() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -17,19 +87,6 @@ export default function SettingsDialog() {
.catch(console.error); .catch(console.error);
}, []); }, []);
// Close dialog on ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && showSettingsDialog) {
setShowSettingsDialog(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showSettingsDialog, setShowSettingsDialog]);
if (!showSettingsDialog) return null;
const tabs = [ const tabs = [
{ id: "general", label: t("settings.general", "General"), icon: "settings" }, { id: "general", label: t("settings.general", "General"), icon: "settings" },
{ id: "appearance", label: t("settings.appearance", "Appearance"), icon: "palette" }, { id: "appearance", label: t("settings.appearance", "Appearance"), icon: "palette" },
@@ -42,45 +99,26 @@ export default function SettingsDialog() {
]; ];
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 text-sm" style={{ fontFamily: appSettings.appearance.font_family }}> <Dialog open={showSettingsDialog} onOpenChange={(v) => !v && setShowSettingsDialog(false)}>
{/* Backdrop */} <DialogContent
<div className="w-full max-w-4xl sm:max-w-4xl h-[80vh] p-0 gap-0 flex flex-col sm:flex-row overflow-hidden"
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity" showCloseButton={false}
onClick={() => setShowSettingsDialog(false)} style={{ fontFamily: appSettings.appearance.font_family }}
/>
{/* Dialog */}
<div
className="relative w-full max-w-4xl h-[80vh] flex flex-col sm:flex-row rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
style={{
backgroundColor: "var(--df-bg-panel)",
color: "var(--df-text)",
border: "1px solid var(--df-border)",
}}
onClick={(e) => e.stopPropagation()}
> >
{/* Sidebar */} {/* Sidebar */}
<div <div className="w-full sm:w-64 shrink-0 flex flex-col border-r bg-background overflow-y-auto">
className="w-full sm:w-64 flex-shrink-0 flex flex-col border-r overflow-y-auto" <div className="p-6 border-b shrink-0 flex items-center gap-3">
style={{ borderColor: "var(--df-border)", backgroundColor: "var(--df-bg)" }} <span className="material-icons text-2xl text-primary">settings</span>
>
<div className="p-6 border-b shrink-0 flex items-center gap-3" style={{ borderColor: "var(--df-border)" }}>
<span className="material-icons text-2xl" style={{ color: "var(--df-primary)" }}>settings</span>
<h2 className="text-xl font-semibold">{t("settings.title", "Settings")}</h2> <h2 className="text-xl font-semibold">{t("settings.title", "Settings")}</h2>
</div> </div>
<div className="flex-1 py-3 px-3 space-y-1"> <div className="flex-1 py-3 px-3 space-y-1">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-left transition-colors" className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-left transition-colors ${activeTab === tab.id ? "bg-primary/15 text-primary" : "hover:bg-accent"}`}
style={{
backgroundColor: activeTab === tab.id ? "color-mix(in srgb, var(--df-primary) 15%, transparent)" : "transparent",
color: activeTab === tab.id ? "var(--df-primary)" : "var(--df-text)",
}}
> >
<span className="material-icons text-[18px]" style={{ color: activeTab === tab.id ? "var(--df-primary)" : "var(--df-text-muted)" }}>{tab.icon}</span> <span className={`material-icons text-[18px] ${activeTab === tab.id ? "text-primary" : "text-muted-foreground"}`}>{tab.icon}</span>
{tab.label} {tab.label}
</button> </button>
))} ))}
@@ -88,113 +126,84 @@ export default function SettingsDialog() {
</div> </div>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 flex flex-col min-w-0" style={{ backgroundColor: "var(--df-bg-panel)" }}> <div className="flex-1 flex flex-col min-w-0">
<div className="p-6 border-b shrink-0 flex items-center justify-between" style={{ borderColor: "var(--df-border)" }}> <div className="p-6 border-b shrink-0 flex items-center justify-between">
<h3 className="text-2xl font-semibold"> <h3 className="text-2xl font-semibold">
{tabs.find(t => t.id === activeTab)?.label} {tabs.find(t => t.id === activeTab)?.label}
</h3> </h3>
<button <Button
variant="ghost"
size="icon-sm"
onClick={() => setShowSettingsDialog(false)} onClick={() => setShowSettingsDialog(false)}
className="p-1 rounded-md transition-colors hover:bg-black/10 dark:hover:bg-white/10"
style={{ color: "var(--df-text-muted)" }}
> >
<span className="material-icons text-xl">close</span> <span className="material-icons text-xl">close</span>
</button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-6 md:p-8"> <div className="flex-1 overflow-y-auto p-6 md:p-8">
<div className="max-w-2xl text-base space-y-6"> <div className="max-w-2xl text-base space-y-6">
{/* ── General Tab ──────────────────────────────────────── */}
{activeTab === "general" && ( {activeTab === "general" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.startupRestore", "Restore previous session on startup")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.startupRestore", "Restore previous session on startup")}</label> desc={t("settings.startupRestoreDesc", "Automatically reconnect to tabs that were open when you last closed the app.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.startupRestoreDesc", "Automatically reconnect to tabs that were open when you last closed the app.")}</p> >
</div> <SettingCheckbox checked={appSettings.general.startup_restore} onChange={(v) => updateAppSettings({ general: { ...appSettings.general, startup_restore: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.general.startup_restore}
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, startup_restore: e.target.checked } })}
/>
</div>
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultLocalShell", "Default Local Shell")}</label> label={t("settings.defaultLocalShell", "Default Local Shell")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.defaultLocalShellDesc", "The shell path to use when opening a local terminal.")}</p> desc={t("settings.defaultLocalShellDesc", "The shell path to use when opening a local terminal.")}
<input value={appSettings.general.default_local_shell}
type="text" onChange={(e) => updateAppSettings({ general: { ...appSettings.general, default_local_shell: e.target.value } })}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" />
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.general.default_local_shell}
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, default_local_shell: e.target.value } })}
/>
</div>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.minimizeToTray", "Minimize to tray on close")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.minimizeToTray", "Minimize to tray on close")}</label> desc={t("settings.minimizeToTrayDesc", "Keep the application running in the background when the window is closed.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.minimizeToTrayDesc", "Keep the application running in the background when the window is closed.")}</p> >
</div> <SettingCheckbox checked={appSettings.general.minimize_to_tray} onChange={(v) => updateAppSettings({ general: { ...appSettings.general, minimize_to_tray: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.general.minimize_to_tray}
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, minimize_to_tray: e.target.checked } })}
/>
</div>
</div> </div>
)} )}
{/* ── Appearance Tab ───────────────────────────────────── */}
{activeTab === "appearance" && ( {activeTab === "appearance" && (
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.theme", "Theme")}</label> label={t("settings.theme", "Theme")}
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.themeDesc", "Select the color theme for the terminal and application.")}</p> desc={t("settings.themeDesc", "Select the color theme for the terminal and application.")}
<select value={appSettings.appearance.theme || "github-dark"}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, theme: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.appearance.theme || "github-dark"} {themeList.map((tm) => (
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, theme: e.target.value } })} <SelectItem key={tm.id} value={tm.id}>{tm.name}</SelectItem>
> ))}
{themeList.map((tm) => ( </SettingSelect>
<option key={tm.id} value={tm.id}>{tm.name}</option>
))}
</select>
</div>
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.language", "Language")}</label> label={t("settings.language", "Language")}
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.languageDesc", "Select the display language for the application interface.")}</p> desc={t("settings.languageDesc", "Select the display language for the application interface.")}
<select value={uiConfig.language || "en"}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(lng) => { i18n.changeLanguage(lng); updateUiConfig({ language: lng }); }}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={uiConfig.language || "en"} {AVAILABLE_LANGUAGES.map(lng => (
onChange={(e) => { <SelectItem key={lng.id} value={lng.id}>{lng.name}</SelectItem>
const lng = e.target.value; ))}
i18n.changeLanguage(lng); </SettingSelect>
updateUiConfig({ language: lng });
}}
>
{AVAILABLE_LANGUAGES.map(lng => (
<option key={lng.id} value={lng.id}>{lng.name}</option>
))}
</select>
</div>
{/* Font Family */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.fontFamily", "Font Family")}</label> <Label className="font-medium text-sm">{t("settings.fontFamily", "Font Family")}</Label>
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.fontFamilyDesc", "The font family used in the terminal and app UI. Topmost font has highest priority.")}</p> <p className="text-xs text-muted-foreground">{t("settings.fontFamilyDesc", "The font family used in the terminal and app UI. Topmost font has highest priority.")}</p>
<div className="space-y-2"> <div className="space-y-2">
{appSettings.appearance.font_family.split(",").map(f => f.trim()).map((font, idx, arr) => ( {appSettings.appearance.font_family.split(",").map(f => f.trim()).map((font, idx, arr) => (
<div key={idx} className="flex items-center gap-2"> <div key={idx} className="flex items-center gap-2">
<span className="text-xs w-20 flex-shrink-0" style={{ color: "var(--df-text-muted)" }}>{idx === 0 ? t("settings.fontPrimary", "Primary") : t("settings.fontFallback", "Fallback") + " " + idx}</span> <span className="text-xs w-20 shrink-0 text-muted-foreground">{idx === 0 ? t("settings.fontPrimary", "Primary") : `${t("settings.fontFallback", "Fallback")} ${idx}`}</span>
<select <select
className="flex-1 px-3 py-1.5 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" className="flex-1 h-9 rounded-md border bg-transparent px-3 text-sm shadow-xs focus:outline-none"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={systemFonts.includes(font) ? font : ""} value={systemFonts.includes(font) ? font : ""}
onChange={(e) => { onChange={(e) => {
const newFonts = [...arr]; const newFonts = [...arr];
@@ -202,243 +211,166 @@ export default function SettingsDialog() {
updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.filter(Boolean).join(", ") } }); updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.filter(Boolean).join(", ") } });
}} }}
> >
{/* Ensure a blank/fallback option exists if the font is missing from the system list initially */ {!systemFonts.includes(font) && <option value={font}>{font} (Custom/Missing)</option>}
!systemFonts.includes(font) && <option value={font}>{font} (Custom/Missing)</option>}
{systemFonts.map(f => <option key={f} value={f}>{f}</option>)} {systemFonts.map(f => <option key={f} value={f}>{f}</option>)}
</select> </select>
<button <Button
variant="ghost"
size="icon-xs"
className="text-destructive hover:bg-destructive/10"
title={t("common.remove", "Remove")}
onClick={() => { onClick={() => {
const newFonts = arr.filter((_, i) => i !== idx); const newFonts = arr.filter((_, i) => i !== idx);
if (newFonts.length === 0) newFonts.push("Consolas"); // Ensure at least one if (newFonts.length === 0) newFonts.push("Consolas");
updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.join(", ") } }); updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.join(", ") } });
}} }}
className="p-1.5 rounded text-red-500 hover:bg-red-500/10 transition-colors"
title={t("common.remove", "Remove")}
> >
<span className="material-icons text-[16px]">close</span> <span className="material-icons text-[16px]">close</span>
</button> </Button>
</div> </div>
))} ))}
</div> </div>
<button <Button
variant="ghost"
size="xs"
className="text-primary"
onClick={() => { onClick={() => {
const newFonts = [...appSettings.appearance.font_family.split(",").map(f => f.trim()), systemFonts[0] || "Arial"]; const newFonts = [...appSettings.appearance.font_family.split(",").map(f => f.trim()), systemFonts[0] || "Arial"];
updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.join(", ") } }); updateAppSettings({ appearance: { ...appSettings.appearance, font_family: newFonts.join(", ") } });
}} }}
className="flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors mt-1"
style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 10%, transparent)", color: "var(--df-primary)" }}
> >
<span className="material-icons text-[14px]">add</span> {t("settings.addFallbackFont", "Add Fallback")} <span className="material-icons text-[14px]">add</span> {t("settings.addFallbackFont", "Add Fallback")}
</button> </Button>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.fontSize", "Font Size (px)")}</label> label={t("settings.fontSize", "Font Size (px)")}
<input type="number"
type="number" min={8} max={72}
min={8} max={72} value={appSettings.appearance.font_size}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, font_size: parseInt(e.target.value) || 14 } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.appearance.font_size}
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, font_size: parseInt(e.target.value) || 14 } })}
/>
</div>
<div className="space-y-1">
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.cursorStyle", "Cursor Style")}</label>
<select
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.appearance.cursor_style}
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, cursor_style: e.target.value } })}
>
<option value="block">{t("settings.cursorBlock", "Block")}</option>
<option value="underline">{t("settings.cursorUnderline", "Underline")}</option>
<option value="bar">{t("settings.cursorBar", "Bar")}</option>
</select>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.cursorBlink", "Cursor Blink")}</label>
</div>
<input
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.appearance.cursor_blink}
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, cursor_blink: e.target.checked } })}
/> />
<SettingSelect
label={t("settings.cursorStyle", "Cursor Style")}
value={appSettings.appearance.cursor_style}
onValueChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, cursor_style: v } })}
>
<SelectItem value="block">{t("settings.cursorBlock", "Block")}</SelectItem>
<SelectItem value="underline">{t("settings.cursorUnderline", "Underline")}</SelectItem>
<SelectItem value="bar">{t("settings.cursorBar", "Bar")}</SelectItem>
</SettingSelect>
</div> </div>
<div className="flex items-center justify-between"> <SettingRow label={t("settings.cursorBlink", "Cursor Blink")}>
<div> <SettingCheckbox checked={appSettings.appearance.cursor_blink} onChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, cursor_blink: v } })} />
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.fontLigatures", "Enable Font Ligatures")}</label> </SettingRow>
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.fontLigaturesDesc", "Combine multiple characters into a single typographical glyph.")}</p>
</div>
<input
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.appearance.ligatures}
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, ligatures: e.target.checked } })}
/>
</div>
<SettingRow
label={t("settings.fontLigatures", "Enable Font Ligatures")}
desc={t("settings.fontLigaturesDesc", "Combine multiple characters into a single typographical glyph.")}
>
<SettingCheckbox checked={appSettings.appearance.ligatures} onChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, ligatures: v } })} />
</SettingRow>
</div> </div>
)} )}
{/* ── Terminal Tab ─────────────────────────────────────── */}
{activeTab === "terminal" && ( {activeTab === "terminal" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.scrollbackLines", "Scrollback Buffer (lines)")}</label> label={t("settings.scrollbackLines", "Scrollback Buffer (lines)")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.scrollbackLinesDesc", "Number of lines kept in memory for scrolling up.")}</p> desc={t("settings.scrollbackLinesDesc", "Number of lines kept in memory for scrolling up.")}
<input type="number" min={100} max={100000} step={100}
type="number" value={appSettings.terminal.scrollback_lines}
min={100} max={100000} step={100} onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, scrollback_lines: parseInt(e.target.value) || 5000 } })}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" />
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.terminal.scrollback_lines}
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, scrollback_lines: parseInt(e.target.value) || 5000 } })}
/>
</div>
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.keepAliveInterval", "Keep-Alive Interval (seconds)")}</label> label={t("settings.keepAliveInterval", "Keep-Alive Interval (seconds)")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.keepAliveIntervalDesc", "Send SSH keep-alive packets every X seconds. 0 to disable.")}</p> desc={t("settings.keepAliveIntervalDesc", "Send SSH keep-alive packets every X seconds. 0 to disable.")}
<input type="number" min={0} max={600} step={5}
type="number" value={appSettings.terminal.keep_alive_interval}
min={0} max={600} step={5} onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, keep_alive_interval: parseInt(e.target.value) || 0 } })}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" />
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.terminal.keep_alive_interval}
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, keep_alive_interval: parseInt(e.target.value) || 0 } })}
/>
</div>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.hardwareAcceleration", "Hardware Acceleration")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.hardwareAcceleration", "Hardware Acceleration")}</label> desc={t("settings.hardwareAccelerationDesc", "Use GPU for terminal rendering (WebGL/Canvas). Requires restart.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.hardwareAccelerationDesc", "Use GPU for terminal rendering (WebGL/Canvas). Requires restart.")}</p> >
</div> <SettingCheckbox checked={appSettings.terminal.hardware_acceleration} onChange={(v) => updateAppSettings({ terminal: { ...appSettings.terminal, hardware_acceleration: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.terminal.hardware_acceleration}
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, hardware_acceleration: e.target.checked } })}
/>
</div>
</div> </div>
)} )}
{/* ── Interaction Tab ──────────────────────────────────── */}
{activeTab === "interaction" && ( {activeTab === "interaction" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.copyOnSelect", "Copy on Select")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.copyOnSelect", "Copy on Select")}</label> desc={t("settings.copyOnSelectDesc", "Automatically copy selected text to the clipboard.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.copyOnSelectDesc", "Automatically copy selected text to the clipboard.")}</p> >
</div> <SettingCheckbox checked={appSettings.interaction.copy_on_select} onChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, copy_on_select: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.interaction.copy_on_select}
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, copy_on_select: e.target.checked } })}
/>
</div>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.rightClickPaste", "Right-click Paste")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.rightClickPaste", "Right-click Paste")}</label> desc={t("settings.rightClickPasteDesc", "Paste clipboard content on right-click instead of opening context menu.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.rightClickPasteDesc", "Paste clipboard content on right-click instead of opening context menu.")}</p> >
</div> <SettingCheckbox checked={appSettings.interaction.right_click_paste} onChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, right_click_paste: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.interaction.right_click_paste}
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, right_click_paste: e.target.checked } })}
/>
</div>
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.wordSeparators", "Word Separators")}</label> label={t("settings.wordSeparators", "Word Separators")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.wordSeparatorsDesc", "Characters that separate words for double-click selection.")}</p> desc={t("settings.wordSeparatorsDesc", "Characters that separate words for double-click selection.")}
<input value={appSettings.interaction.word_separators}
type="text" onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, word_separators: e.target.value } })}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" />
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.interaction.word_separators}
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, word_separators: e.target.value } })}
/>
</div>
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultEncoding", "Default Encoding")}</label> label={t("settings.defaultEncoding", "Default Encoding")}
<select value={appSettings.interaction.default_encoding}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, default_encoding: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.interaction.default_encoding} <SelectItem value="UTF-8">UTF-8</SelectItem>
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, default_encoding: e.target.value } })} <SelectItem value="GBK">GBK</SelectItem>
> </SettingSelect>
<option value="UTF-8">UTF-8</option>
<option value="GBK">GBK</option>
</select>
</div>
</div> </div>
)} )}
{/* ── Proxy Tab ────────────────────────────────────────── */}
{activeTab === "proxy" && ( {activeTab === "proxy" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.enableProxy", "Enable Proxy")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.enableProxy", "Enable Proxy")}</label> desc={t("settings.enableProxyDesc", "Route SSH connections through a proxy server.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.enableProxyDesc", "Route SSH connections through a proxy server.")}</p> >
</div> <SettingCheckbox checked={appSettings.proxy.enabled} onChange={(v) => updateAppSettings({ proxy: { ...appSettings.proxy, enabled: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.proxy.enabled}
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, enabled: e.target.checked } })}
/>
</div>
<div className={`space-y-4 ${!appSettings.proxy.enabled ? "opacity-50 pointer-events-none" : ""}`}> <div className={`space-y-4 ${!appSettings.proxy.enabled ? "opacity-50 pointer-events-none" : ""}`}>
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyProtocol", "Protocol")}</label> label={t("settings.proxyProtocol", "Protocol")}
<select value={appSettings.proxy.protocol}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ proxy: { ...appSettings.proxy, protocol: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.proxy.protocol} <SelectItem value="socks5">SOCKS5</SelectItem>
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, protocol: e.target.value } })} <SelectItem value="http">HTTP</SelectItem>
> </SettingSelect>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
</select>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="space-y-1 flex-1"> <div className="flex-1">
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyHost", "Host")}</label> <SettingInput
<input label={t("settings.proxyHost", "Host")}
type="text"
placeholder="127.0.0.1" placeholder="127.0.0.1"
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.proxy.host} value={appSettings.proxy.host}
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, host: e.target.value } })} onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, host: e.target.value } })}
/> />
</div> </div>
<div className="space-y-1 w-24"> <div className="w-24">
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyPort", "Port")}</label> <SettingInput
<input label={t("settings.proxyPort", "Port")}
type="number" type="number" min={1} max={65535}
min={1} max={65535}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.proxy.port || ""} value={appSettings.proxy.port || ""}
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, port: parseInt(e.target.value) || 0 } })} onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, port: parseInt(e.target.value) || 0 } })}
/> />
@@ -448,46 +380,42 @@ export default function SettingsDialog() {
</div> </div>
)} )}
{/* ── Search Tab ───────────────────────────────────────── */}
{activeTab === "search" && ( {activeTab === "search" && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultSearchEngine", "Default Search Engine")}</label> label={t("settings.defaultSearchEngine", "Default Search Engine")}
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.defaultSearchEngineDesc", "The primary engine used when double-clicking or right-clicking to search.")}</p> desc={t("settings.defaultSearchEngineDesc", "The primary engine used when double-clicking or right-clicking to search.")}
<select value={appSettings.search.default_engine}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ search: { ...appSettings.search, default_engine: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.search.default_engine} {appSettings.search.custom_engines.map((engine, idx) => (
onChange={(e) => updateAppSettings({ search: { ...appSettings.search, default_engine: e.target.value } })} <SelectItem key={idx} value={engine.name}>{engine.name}</SelectItem>
> ))}
{appSettings.search.custom_engines.map((engine, idx) => ( </SettingSelect>
<option key={idx} value={engine.name}>{engine.name}</option>
))}
</select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.customEngines", "Search Engines")}</label> <Label className="font-medium text-sm">{t("settings.customEngines", "Search Engines")}</Label>
<button <Button
variant="ghost"
size="xs"
className="text-primary"
onClick={() => { onClick={() => {
const newEngines = [...appSettings.search.custom_engines, { name: "New Engine", url_template: "https://example.com/search?q=%s" }]; const newEngines = [...appSettings.search.custom_engines, { name: "New Engine", url_template: "https://example.com/search?q=%s" }];
updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } }); updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } });
}} }}
className="flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors"
style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 10%, transparent)", color: "var(--df-primary)" }}
> >
<span className="material-icons text-[14px]">add</span> {t("common.add", "Add")} <span className="material-icons text-[14px]">add</span> {t("common.add", "Add")}
</button> </Button>
</div> </div>
<div className="border rounded overflow-hidden" style={{ borderColor: "var(--df-border)" }}> <div className="border rounded-md overflow-hidden">
{appSettings.search.custom_engines.map((engine, i) => ( {appSettings.search.custom_engines.map((engine, i) => (
<div key={i} className="flex items-center gap-2 p-2 border-b last:border-0 hover:bg-black/5 dark:hover:bg-white/5 transition-colors" style={{ borderColor: "var(--df-border)" }}> <div key={i} className="flex items-center gap-2 p-2 border-b last:border-0 hover:bg-accent transition-colors">
<input <Input
type="text"
placeholder="Name" placeholder="Name"
className="w-1/3 px-2 py-1.5 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" className="w-1/3 text-sm"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={engine.name} value={engine.name}
onChange={(e) => { onChange={(e) => {
const newEngines = [...appSettings.search.custom_engines]; const newEngines = [...appSettings.search.custom_engines];
@@ -495,11 +423,9 @@ export default function SettingsDialog() {
updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } }); updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } });
}} }}
/> />
<input <Input
type="text"
placeholder="URL Template (e.g. https://google.com/search?q=%s)" placeholder="URL Template (e.g. https://google.com/search?q=%s)"
className="flex-1 px-2 py-1.5 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" className="flex-1 text-sm"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={engine.url_template} value={engine.url_template}
onChange={(e) => { onChange={(e) => {
const newEngines = [...appSettings.search.custom_engines]; const newEngines = [...appSettings.search.custom_engines];
@@ -507,151 +433,117 @@ export default function SettingsDialog() {
updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } }); updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } });
}} }}
/> />
<button <Button
variant="ghost"
size="icon-xs"
className="text-destructive hover:bg-destructive/10"
title={t("common.delete", "Delete")}
onClick={() => { onClick={() => {
const newEngines = appSettings.search.custom_engines.filter((_, idx) => idx !== i); const newEngines = appSettings.search.custom_engines.filter((_, idx) => idx !== i);
const newDefault = (appSettings.search.default_engine === engine.name) const newDefault = (appSettings.search.default_engine === engine.name) ? (newEngines[0]?.name || "") : appSettings.search.default_engine;
? (newEngines[0]?.name || "")
: appSettings.search.default_engine;
updateAppSettings({ search: { default_engine: newDefault, custom_engines: newEngines } }); updateAppSettings({ search: { default_engine: newDefault, custom_engines: newEngines } });
}} }}
className="p-1.5 rounded text-red-500 hover:bg-red-500/10 transition-colors"
title={t("common.delete", "Delete")}
> >
<span className="material-icons text-[16px]">delete</span> <span className="material-icons text-[16px]">delete</span>
</button> </Button>
</div> </div>
))} ))}
{appSettings.search.custom_engines.length === 0 && ( {appSettings.search.custom_engines.length === 0 && (
<div className="text-center py-6 text-xs" style={{ color: "var(--df-text-muted)" }}> <div className="text-center py-6 text-xs text-muted-foreground">
{t("settings.noCustomEngines", "No search engines available.")} {t("settings.noCustomEngines", "No search engines available.")}
</div> </div>
)} )}
</div> </div>
<p className="text-xs mt-1" style={{ color: "var(--df-text-muted)" }}> <p className="text-xs mt-1 text-muted-foreground">
{t("settings.customEnginesDesc", "Use %s to represent the searched text in the URL template.")} {t("settings.customEnginesDesc", "Use %s to represent the searched text in the URL template.")}
</p> </p>
</div> </div>
</div> </div>
)} )}
{/* ── Translation Tab ──────────────────────────────────── */}
{activeTab === "translation" && ( {activeTab === "translation" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.translationProvider", "Translation Provider")}</label> label={t("settings.translationProvider", "Translation Provider")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.translationProviderDesc", "Select the API provider for translating terminal output.")}</p> desc={t("settings.translationProviderDesc", "Select the API provider for translating terminal output.")}
<select value={appSettings.translation.provider}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ translation: { ...appSettings.translation, provider: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.translation.provider} <SelectItem value="">{t("settings.translationDisabled", "Disabled")}</SelectItem>
onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, provider: e.target.value } })} <SelectItem value="openai">OpenAI</SelectItem>
> <SelectItem value="deepl">DeepL</SelectItem>
<option value="">{t("settings.translationDisabled", "Disabled")}</option> </SettingSelect>
<option value="openai">OpenAI</option>
<option value="deepl">DeepL</option>
</select>
</div>
{appSettings.translation.provider !== "" && ( {appSettings.translation.provider !== "" && (
<div className="space-y-1"> <SettingInput
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.translationApiKey", "API Key")}</label> label={t("settings.translationApiKey", "API Key")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.translationApiKeyDesc", "Enter the API key for your chosen translation provider.")}</p> desc={t("settings.translationApiKeyDesc", "Enter the API key for your chosen translation provider.")}
<input type="password"
type="password" placeholder="sk-..."
placeholder="sk-..." value={appSettings.translation.api_key}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow" onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, api_key: e.target.value } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} />
value={appSettings.translation.api_key}
onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, api_key: e.target.value } })}
/>
</div>
)} )}
</div> </div>
)} )}
{/* ── Security Tab ─────────────────────────────────────── */}
{activeTab === "security" && ( {activeTab === "security" && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-semibold text-sm" style={{ color: "var(--df-text)" }}>{t("settings.credentialStorage", "Credential Storage")}</h4> <h4 className="font-semibold text-sm">{t("settings.credentialStorage", "Credential Storage")}</h4>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.useOsKeyring", "Use OS Keyring")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.useOsKeyring", "Use OS Keyring")}</label> desc={t("settings.useOsKeyringDesc", "Securely store SSH passwords and keys in your system's native keychain.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.useOsKeyringDesc", "Securely store SSH passwords and keys in your system's native keychain (Windows Credential Manager, Keychain Access, etc).")}</p> >
</div> <SettingCheckbox checked={appSettings.security.use_os_keyring} onChange={(v) => updateAppSettings({ security: { ...appSettings.security, use_os_keyring: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.security.use_os_keyring}
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, use_os_keyring: e.target.checked } })}
/>
</div>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.requireMasterPassword", "Require Master Password")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.requireMasterPassword", "Require Master Password")}</label> desc={t("settings.requireMasterPasswordDesc", "Require a master password to encrypt your session database.")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.requireMasterPasswordDesc", "Require a master password to encrypt your session database.")}</p> >
</div> <SettingCheckbox checked={appSettings.security.require_master_password} onChange={(v) => updateAppSettings({ security: { ...appSettings.security, require_master_password: v } })} />
<input </SettingRow>
type="checkbox"
className="w-4 h-4 rounded appearance-none checked:bg-[var(--df-primary)] border checked:border-transparent transition-colors relative after:content-[''] after:absolute after:top-[2px] after:left-[5px] after:w-1.5 after:h-2.5 after:border-r-2 after:border-b-2 after:border-white after:rotate-45 after:hidden checked:after:block"
style={{ borderColor: "var(--df-border)" }}
checked={appSettings.security.require_master_password}
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, require_master_password: e.target.checked } })}
/>
</div>
</div> </div>
<div className="border-t pt-4 space-y-4" style={{ borderColor: "var(--df-border)" }}> <div className="border-t pt-4 space-y-4">
<h4 className="font-semibold text-sm" style={{ color: "var(--df-text)" }}>{t("settings.sessionSecurity", "Session Security")}</h4> <h4 className="font-semibold text-sm">{t("settings.sessionSecurity", "Session Security")}</h4>
<div className="flex items-center justify-between"> <SettingRow
<div> label={t("settings.idleLockMinutes", "Session Lock Interval")}
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.idleLockMinutes", "Session Lock Interval")}</label> desc={t("settings.idleLockMinutesDesc", "Lock the application after a specified duration of inactivity (0 to disable).")}
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.idleLockMinutesDesc", "Lock the application after a specified duration of inactivity (0 to disable).")}</p> >
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <Input
type="number" type="number" min={0} max={1440}
min={0} max={1440} className="w-20 text-sm"
className="w-20 px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow"
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
value={appSettings.security.idle_lock_minutes} value={appSettings.security.idle_lock_minutes}
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, idle_lock_minutes: parseInt(e.target.value) || 0 } })} onChange={(e) => updateAppSettings({ security: { ...appSettings.security, idle_lock_minutes: parseInt(e.target.value) || 0 } })}
/> />
<span className="text-sm" style={{ color: "var(--df-text-muted)" }}>{t("common.minutes", "mins")}</span> <span className="text-sm text-muted-foreground">{t("common.minutes", "mins")}</span>
</div> </div>
</div> </SettingRow>
<div className="space-y-1"> <SettingSelect
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.hostKeyPolicy", "Host Key Policy")}</label> label={t("settings.hostKeyPolicy", "Host Key Policy")}
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.hostKeyPolicyDesc", "How the application handles unrecognized SSH host keys.")}</p> desc={t("settings.hostKeyPolicyDesc", "How the application handles unrecognized SSH host keys.")}
<select value={appSettings.security.host_key_policy}
className="w-full px-3 py-2 rounded text-sm border focus:outline-none focus:ring-1 transition-shadow appearance-none" onValueChange={(v) => updateAppSettings({ security: { ...appSettings.security, host_key_policy: v } })}
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }} >
value={appSettings.security.host_key_policy} <SelectItem value="strict">{t("settings.hostKeyStrict", "Strict (Reject unknown hosts)")}</SelectItem>
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, host_key_policy: e.target.value } })} <SelectItem value="prompt">{t("settings.hostKeyPrompt", "Prompt (Ask user for confirmation)")}</SelectItem>
> <SelectItem value="accept">{t("settings.hostKeyAccept", "Accept (Automatically add new hosts)")}</SelectItem>
<option value="strict">{t("settings.hostKeyStrict", "Strict (Reject unknown hosts)")}</option> </SettingSelect>
<option value="prompt">{t("settings.hostKeyPrompt", "Prompt (Ask user for confirmation)")}</option>
<option value="accept">{t("settings.hostKeyAccept", "Accept (Automatically add new hosts)")}</option>
</select>
</div>
</div> </div>
</div> </div>
)} )}
{/* Placeholder for un-implemented tabs */}
{activeTab !== "general" && activeTab !== "appearance" && activeTab !== "terminal" && activeTab !== "interaction" && activeTab !== "proxy" && activeTab !== "search" && activeTab !== "translation" && activeTab !== "security" && (
<div style={{ color: "var(--df-text-muted)" }}>
Configuration options for <strong>{activeTab}</strong> will be implemented here soon.
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </DialogContent>
</div> </Dialog>
); );
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { useApp } from "../../context/AppContext";
import { invoke } from "../../lib/invoke"; import { invoke } from "../../lib/invoke";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
import type { Group, SavedConnection, SessionType } from "../../types"; import type { Group, SavedConnection, SessionType } from "../../types";
import { useToast } from "../toast/ToastContext"; import { toast } from "sonner";
interface SavedConnectionsProps { interface SavedConnectionsProps {
onEditConnection: (connection: SavedConnection) => void; onEditConnection: (connection: SavedConnection) => void;
@@ -27,7 +27,7 @@ export default function SavedConnections({
const [connectingId, setConnectingId] = useState<string | null>(null); const [connectingId, setConnectingId] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null); const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const toast = useToast();
// Build grouped view: use savedGroups order, include empty groups // Build grouped view: use savedGroups order, include empty groups
const { groups, ungrouped } = useMemo(() => { const { groups, ungrouped } = useMemo(() => {
+2 -2
View File
@@ -10,7 +10,7 @@ import PropertiesDialog, { PropertiesDialogData } from "../dialog/PropertiesDial
import RenameDialog, { RenameDialogData } from "../dialog/RenameDialog"; import RenameDialog, { RenameDialogData } from "../dialog/RenameDialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useApp } from "../../context/AppContext"; import { useApp } from "../../context/AppContext";
import { useToast } from "../toast/ToastContext"; import { toast } from "sonner";
interface FileEntry { interface FileEntry {
name: string; name: string;
@@ -154,7 +154,7 @@ function formatSize(bytes: number): string {
export default function FileExplorer({ activeSessionId }: FileExplorerProps) { export default function FileExplorer({ activeSessionId }: FileExplorerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showContextMenu } = useApp(); const { showContextMenu } = useApp();
const toast = useToast();
const [files, setFiles] = useState<FileEntry[]>([]); const [files, setFiles] = useState<FileEntry[]>([]);
const [currentPath, setCurrentPath] = useState(""); const [currentPath, setCurrentPath] = useState("");
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+255
View File
@@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+188
View File
@@ -0,0 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+21
View File
@@ -0,0 +1,21 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+91
View File
@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+79
View File
@@ -1,6 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: "JetBrains Mono", "Inter", sans-serif;
--font-display: "JetBrains Mono", "Inter", sans-serif; --font-display: "JetBrains Mono", "Inter", sans-serif;
--font-mono: "JetBrains Mono", monospace; --font-mono: "JetBrains Mono", monospace;
@@ -8,6 +12,70 @@
--color-primary-hover: var(--df-primary-hover); --color-primary-hover: var(--df-primary-hover);
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/* ── Map shadcn CSS vars to existing --df-* theme system ───────────────── */
:root {
--radius: 0.5rem;
--background: var(--df-bg);
--foreground: var(--df-text);
--card: var(--df-bg-panel);
--card-foreground: var(--df-text);
--popover: var(--df-bg-panel);
--popover-foreground: var(--df-text);
--primary: var(--df-primary);
--primary-foreground: #fff;
--secondary: var(--df-bg-hover);
--secondary-foreground: var(--df-text);
--muted: var(--df-bg-hover);
--muted-foreground: var(--df-text-muted);
--accent: var(--df-bg-hover);
--accent-foreground: var(--df-text);
--destructive: #dc2626;
--border: var(--df-border);
--input: var(--df-border);
--ring: var(--df-primary);
}
/* ── Theme-aware scrollbar ──────────────────────────────────────────────── */ /* ── Theme-aware scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -53,6 +121,7 @@ body,
/* ── Animations ─────────────────────────────────────────────────────────── */ /* ── Animations ─────────────────────────────────────────────────────────── */
@keyframes blink { @keyframes blink {
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
@@ -91,3 +160,13 @@ body,
.df-hover-primary:hover { .df-hover-primary:hover {
background-color: color-mix(in srgb, var(--df-primary) 20%, transparent); background-color: color-mix(in srgb, var(--df-primary) 20%, transparent);
} }
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+7 -1
View File
@@ -18,7 +18,13 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
+10 -4
View File
@@ -1,3 +1,4 @@
import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
@@ -8,6 +9,11 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false, clearScreen: false,
server: { server: {
@@ -16,10 +22,10 @@ export default defineConfig(async () => ({
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
host, host,
port: 1421, port: 1421,
} }
: undefined, : undefined,
watch: { watch: {
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],