style: shadcn ui
This commit is contained in:
1 parent
5be7b2956b
commit
21884eb17b
28 files changed
+6026
-1259
No files matched your search
@@ -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
@@ -32,20 +32,30 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.8.11",
|
||||
"lucide-react": "^0.575.0",
|
||||
"material-icons": "^1.13.14",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^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": {
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
|
||||
Generated
+4140
-8
File diff suppressed because it is too large.
Load diff
+221
-224
@@ -15,8 +15,7 @@ import FileTransfer from "./components/sidebar/FileTransfer";
|
||||
import QuickCommands from "./components/terminal/QuickCommands";
|
||||
import TabBar from "./components/terminal/TabBar";
|
||||
import XTerminal from "./components/terminal/XTerminal";
|
||||
import ToastContainer from "./components/toast/ToastContainer";
|
||||
import { ToastProvider } from "./components/toast/ToastContext";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TransferProvider } from "./context/TransferContext";
|
||||
import { useApp } from "./context/AppContext";
|
||||
import type { SavedConnection } from "./types";
|
||||
@@ -159,254 +158,252 @@ function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<TransferProvider>
|
||||
<div
|
||||
className="font-display h-screen flex flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
onNewSession={handleNewSession}
|
||||
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
|
||||
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
|
||||
onAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
<TransferProvider>
|
||||
<div
|
||||
className="font-display h-screen flex flex-col overflow-hidden"
|
||||
style={{ backgroundColor: "var(--df-bg)", color: "var(--df-text)" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
onNewSession={handleNewSession}
|
||||
onToggleLeft={() => setMobileLeftOpen(!mobileLeftOpen)}
|
||||
onToggleRight={() => setMobileRightOpen(!mobileRightOpen)}
|
||||
onAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex overflow-hidden relative">
|
||||
{/* Backdrop for mobile */}
|
||||
{(mobileLeftOpen || mobileRightOpen) && (
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex overflow-hidden relative">
|
||||
{/* Backdrop for mobile */}
|
||||
{(mobileLeftOpen || mobileRightOpen) && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => {
|
||||
setMobileLeftOpen(false);
|
||||
setMobileRightOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar - File Explorer & File Transfer */}
|
||||
{(uiConfig.show_file_explorer || uiConfig.show_file_transfer) && (
|
||||
<>
|
||||
<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
|
||||
style={{ width: uiConfig.left_width, backgroundColor: "var(--df-bg)" }}
|
||||
className={`
|
||||
style={{ width: uiConfig.left_width, backgroundColor: "var(--df-bg)" }}
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-40 flex flex-col shadow-xl transition-transform duration-200
|
||||
lg:relative lg:translate-x-0 lg:z-0 lg:shadow-none
|
||||
${mobileLeftOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
`}
|
||||
>
|
||||
{/* 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 */}
|
||||
<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)" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileLeftOpen(false)}
|
||||
onClick={() => setMobileRightOpen(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} />
|
||||
{/* 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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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"
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
{/* Status Bar */}
|
||||
<StatusBar />
|
||||
|
||||
{/* 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>
|
||||
{/* New Session Dialog */}
|
||||
<NewSessionDialog
|
||||
open={showNewSession}
|
||||
onClose={() => {
|
||||
setShowNewSession(false);
|
||||
setEditingConnection(undefined);
|
||||
}}
|
||||
onConnect={handleSessionConnected}
|
||||
onSaved={() => {
|
||||
setShowNewSession(false);
|
||||
setEditingConnection(undefined);
|
||||
refreshConnections();
|
||||
}}
|
||||
initialData={editingConnection}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
|
||||
<SettingsDialog />
|
||||
|
||||
{/* Right Sidebar: three independent panels */}
|
||||
{(uiConfig.show_saved_connections ||
|
||||
uiConfig.show_active_sessions ||
|
||||
uiConfig.show_command_history) && (
|
||||
<>
|
||||
{/* Only show resize handle on desktop */}
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
onResize={handleRightResize}
|
||||
className="hidden md:block"
|
||||
/>
|
||||
<aside
|
||||
style={{
|
||||
width: uiConfig.right_width,
|
||||
backgroundColor: "var(--df-bg-panel)",
|
||||
borderColor: "var(--df-border)",
|
||||
}}
|
||||
className={`
|
||||
fixed inset-y-0 right-0 z-50 flex flex-col shadow-xl transition-transform duration-200 border-l
|
||||
md:relative md:translate-x-0 md:z-0 md:shadow-none
|
||||
${mobileRightOpen ? "translate-x-0" : "translate-x-full"}
|
||||
`}
|
||||
>
|
||||
{/* Mobile placeholder for header height if needed, or close button */}
|
||||
<div
|
||||
className="md:hidden h-10 flex items-center justify-end px-2 border-b shrink-0"
|
||||
style={{ borderColor: "var(--df-border)" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileRightOpen(false)}
|
||||
style={{ color: "var(--df-text-muted)" }}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Saved Connections - fixed pixel height at top */}
|
||||
{uiConfig.show_saved_connections && (
|
||||
<>
|
||||
<div
|
||||
style={{ height: uiConfig.saved_conn_height }}
|
||||
className="shrink-0 overflow-hidden"
|
||||
>
|
||||
<SavedConnections
|
||||
onEditConnection={handleEditConnection}
|
||||
onSessionCreated={handleSessionConnected}
|
||||
/>
|
||||
</div>
|
||||
{/* Show resize handle only if there's something below it */}
|
||||
{(uiConfig.show_active_sessions || uiConfig.show_command_history) && (
|
||||
<ResizeHandle direction="vertical" onResize={handleSavedConnResize} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Active Sessions - flexible middle */}
|
||||
{uiConfig.show_active_sessions && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ActiveSessions onSessionClick={handleSessionClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command History - fixed pixel height at bottom */}
|
||||
{uiConfig.show_command_history && (
|
||||
<>
|
||||
{/* Show resize handle only if there's something above it */}
|
||||
{(uiConfig.show_saved_connections || uiConfig.show_active_sessions) && (
|
||||
<ResizeHandle direction="vertical" onResize={handleHistoryResize} />
|
||||
)}
|
||||
<div
|
||||
style={{ height: uiConfig.history_height }}
|
||||
className="shrink-0 overflow-hidden"
|
||||
>
|
||||
<CommandHistory onCommandSend={handleHistoryCommand} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Status Bar */}
|
||||
<StatusBar />
|
||||
|
||||
{/* New Session Dialog */}
|
||||
<NewSessionDialog
|
||||
open={showNewSession}
|
||||
onClose={() => {
|
||||
setShowNewSession(false);
|
||||
setEditingConnection(undefined);
|
||||
}}
|
||||
onConnect={handleSessionConnected}
|
||||
onSaved={() => {
|
||||
setShowNewSession(false);
|
||||
setEditingConnection(undefined);
|
||||
refreshConnections();
|
||||
}}
|
||||
initialData={editingConnection}
|
||||
/>
|
||||
|
||||
<AboutDialog open={showAbout} onClose={() => setShowAbout(false)} />
|
||||
<SettingsDialog />
|
||||
|
||||
<GlobalContextMenu />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</TransferProvider>
|
||||
</ToastProvider>
|
||||
<GlobalContextMenu />
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
</TransferProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import pkg from "../../../package.json";
|
||||
import DragonflyLogo from "../DragonflyLogo";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AboutDialogProps {
|
||||
open: boolean;
|
||||
@@ -22,69 +31,46 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="rounded-lg w-[320px] shadow-2xl border flex flex-col items-center p-6 space-y-4"
|
||||
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||
>
|
||||
<DragonflyLogo className="w-24 h-24 object-contain" />
|
||||
|
||||
<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)" }}>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="w-[320px] sm:max-w-[320px] flex flex-col items-center p-6 gap-4">
|
||||
<DialogHeader className="items-center">
|
||||
<DragonflyLogo className="w-24 h-24 object-contain" />
|
||||
<DialogTitle className="text-lg">{appName}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
v{appVersion}
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</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")}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 w-full pt-2">
|
||||
<button
|
||||
className="flex-1 py-2 text-xs font-medium rounded border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: "var(--df-border)", color: "var(--df-text)" }}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => openUrl(pkg.homepage)}
|
||||
>
|
||||
{t("about.website")}
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 py-2 text-xs font-medium rounded border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: "var(--df-border)", color: "var(--df-text)" }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => openUrl(pkg.bugs.url)}
|
||||
>
|
||||
{t("about.issues")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<button
|
||||
className="w-full py-2 text-xs font-medium rounded transition-colors hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: "var(--df-primary)",
|
||||
color: "#fff"
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<DialogFooter className="w-full">
|
||||
<Button size="sm" className="w-full text-xs" onClick={onClose}>
|
||||
{t("about.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 {
|
||||
sessionId: string;
|
||||
@@ -18,16 +25,6 @@ interface AutoUploadDialogProps {
|
||||
export default function AutoUploadDialog({ data, onClose, onAlwaysUpload }: AutoUploadDialogProps) {
|
||||
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) => {
|
||||
if (always) {
|
||||
onAlwaysUpload(data.sessionId, data.localPath);
|
||||
@@ -41,63 +38,45 @@ export default function AutoUploadDialog({ data, onClose, onAlwaysUpload }: Auto
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
|
||||
<div
|
||||
className="rounded-lg shadow-2xl p-5 w-96 flex flex-col gap-4"
|
||||
style={{
|
||||
backgroundColor: "var(--df-bg-panel)",
|
||||
border: "1px solid var(--df-border)",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b pb-3" style={{ borderColor: 'var(--df-border)' }}>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full" style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 15%, transparent)" }}>
|
||||
<span className="material-icons text-[18px]" style={{ color: "var(--df-primary)" }}>cloud_sync</span>
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="w-96 sm:max-w-96">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full"
|
||||
style={{ backgroundColor: "color-mix(in srgb, var(--df-primary) 15%, transparent)" }}
|
||||
>
|
||||
<span className="material-icons text-[18px] text-primary">cloud_sync</span>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{t("fileExplorer.fileModified", "File Modified Locally")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm" style={{ color: "var(--df-text)" }}>
|
||||
{t("fileExplorer.fileModified", "File Modified Locally")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-xs break-all leading-relaxed" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("fileExplorer.uploadPrompt", "The following file was modified. Do you want to upload it to the remote server?")}<br /><br />
|
||||
<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}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t" style={{ borderColor: 'var(--df-border)' }}>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
<DialogDescription className="text-xs break-all leading-relaxed">
|
||||
{t("fileExplorer.uploadPrompt", "The following file was modified. Do you want to upload it to the remote server?")}
|
||||
<br /><br />
|
||||
<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}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={onClose}>
|
||||
{t("dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-xs flex-1" onClick={() => handleUpload(true)}>
|
||||
{t("fileExplorer.alwaysUpload", "Always")}
|
||||
</button>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" className="text-xs flex-1" onClick={() => handleUpload(false)}>
|
||||
{t("fileExplorer.uploadOnce", "Upload")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export default function GlobalContextMenu() {
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase so we close it *before* other clicks register
|
||||
if (contextMenu) {
|
||||
window.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("contextmenu", handleClick, true);
|
||||
@@ -34,11 +33,10 @@ export default function GlobalContextMenu() {
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
// Simple bounds checking so the menu doesn't overflow screen edges
|
||||
const maxW = window.innerWidth;
|
||||
const maxH = window.innerHeight;
|
||||
const menuWidth = 200; // approximate
|
||||
const menuHeight = contextMenu.items.length * 36; // approximate per item
|
||||
const menuWidth = 200;
|
||||
const menuHeight = contextMenu.items.length * 36;
|
||||
|
||||
let { x, y } = contextMenu;
|
||||
if (x + menuWidth > maxW) x = maxW - menuWidth - 10;
|
||||
@@ -47,44 +45,27 @@ export default function GlobalContextMenu() {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[10000] py-1 rounded shadow-2xl border text-xs"
|
||||
style={{
|
||||
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.
|
||||
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={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.items.map((item, idx) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`div-${idx}`}
|
||||
className="border-b my-1"
|
||||
style={{ borderColor: "var(--df-border)" }}
|
||||
/>
|
||||
);
|
||||
return <div key={`div-${idx}`} className="bg-border -mx-1 my-1 h-px" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`item-${idx}`}
|
||||
className="px-3 py-1.5 cursor-pointer flex items-center gap-2 transition-colors"
|
||||
style={{ color: item.color || "inherit" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--df-bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")}
|
||||
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 || undefined }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hideContextMenu();
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
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 {
|
||||
sessionId: string;
|
||||
@@ -24,16 +33,6 @@ export default function MoveDialog({ data, onClose, onSuccess }: MoveDialogProps
|
||||
setDialogInput(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 () => {
|
||||
if (!dialogInput || dialogInput === data.oldPath) {
|
||||
onClose();
|
||||
@@ -56,41 +55,33 @@ export default function MoveDialog({ data, onClose, onSuccess }: MoveDialogProps
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
<div className="rounded shadow-xl p-4 w-96" style={{ backgroundColor: "var(--df-bg-panel)", border: "1px solid var(--df-border)" }}>
|
||||
<h3 className="font-bold mb-4">{t("fileExplorer.moveTo", { name: data.name })}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full text-sm p-2 rounded border mb-4 focus:outline-none"
|
||||
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
|
||||
value={dialogInput}
|
||||
onChange={(e) => setDialogInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleMoveSubmit()}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
<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}
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && !isSubmitting && onClose()}>
|
||||
<DialogContent className="w-96 sm:max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{t("fileExplorer.moveTo", { name: data.name })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t("fileExplorer.moveTo", { name: data.name })}</Label>
|
||||
<Input
|
||||
className="text-sm"
|
||||
value={dialogInput}
|
||||
onChange={(e) => setDialogInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleMoveSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
|
||||
{t("dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleMoveSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>}
|
||||
{t("dialog.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,17 @@ import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -12,13 +23,6 @@ interface NewSessionDialogProps {
|
||||
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. */
|
||||
export default function NewSessionDialog({
|
||||
open,
|
||||
@@ -108,18 +112,8 @@ export default function NewSessionDialog({
|
||||
}
|
||||
}, [open, initialData, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open && !connecting && !showGroupDropdown) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, connecting, showGroupDropdown, resetForm, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (connecting) return;
|
||||
resetForm();
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className="rounded-lg w-[480px] shadow-2xl border"
|
||||
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||
>
|
||||
{/* 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)" }}>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="w-[480px] sm:max-w-[480px] p-0 gap-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle className="text-sm">
|
||||
{initialData ? t("dialog.editConnection") : t("dialog.newConnection")}
|
||||
</h2>
|
||||
<span
|
||||
className="material-icons text-base cursor-pointer transition-opacity hover:opacity-70"
|
||||
style={{ color: "var(--df-text-muted)" }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{!initialData && (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 p-3 border rounded transition-colors text-left hover:opacity-90"
|
||||
style={{ backgroundColor: "var(--df-bg-hover)", borderColor: "var(--df-border)" }}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-3 p-3 h-auto text-left justify-start"
|
||||
onClick={handleConnectLocal}
|
||||
disabled={connecting}
|
||||
>
|
||||
<span className="material-icons text-xl text-cyan-400">terminal</span>
|
||||
<div>
|
||||
<div className="text-xs font-medium" style={{ color: "var(--df-text)" }}>
|
||||
{t("dialog.localTerminal")}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--df-text-dimmed)" }}>
|
||||
{t("dialog.openLocalShell")}
|
||||
</div>
|
||||
<div className="text-xs font-medium">{t("dialog.localTerminal")}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t("dialog.openLocalShell")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!initialData && (
|
||||
<div
|
||||
className="flex items-center gap-3 text-[10px] uppercase tracking-wider"
|
||||
style={{ color: "var(--df-text-dimmed)" }}
|
||||
>
|
||||
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div>
|
||||
<div className="flex items-center gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<div className="flex-1 border-t" />
|
||||
<span>{t("dialog.sshConnection")}</span>
|
||||
<div className="flex-1 border-t" style={{ borderColor: "var(--df-border)" }}></div>
|
||||
<div className="flex-1 border-t" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + Group */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.connectionName")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.connectionName")}</Label>
|
||||
<Input
|
||||
className="mt-1 text-xs h-8"
|
||||
placeholder={t("dialog.serverPlaceholder")}
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-52 relative" ref={groupRef}>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.group")}
|
||||
</label>
|
||||
<button
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.group")}</Label>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between rounded px-3 py-2 text-xs text-left border transition-colors"
|
||||
style={inputStyle}
|
||||
variant="outline"
|
||||
className="w-full mt-1 h-8 justify-between text-xs font-normal"
|
||||
onClick={() => setShowGroupDropdown(!showGroupDropdown)}
|
||||
>
|
||||
<span style={{ color: group ? "var(--df-text)" : "var(--df-text-dimmed)" }}>
|
||||
<span className={group ? "" : "text-muted-foreground"}>
|
||||
{group || t("dialog.none")}
|
||||
</span>
|
||||
<span className="material-icons text-xs" style={{ color: "var(--df-text-dimmed)" }}>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
<span className="material-icons text-xs text-muted-foreground">expand_more</span>
|
||||
</Button>
|
||||
{showGroupDropdown && (
|
||||
<div
|
||||
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 className="absolute top-full left-0 right-0 mt-1 border rounded-md shadow-xl z-10 overflow-hidden bg-popover">
|
||||
<div
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
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 = "";
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer transition-colors hover:bg-accent ${!group ? "bg-primary/15 text-primary" : "text-muted-foreground"}`}
|
||||
onClick={() => {
|
||||
setGroup("");
|
||||
setShowGroupDropdown(false);
|
||||
@@ -300,21 +246,7 @@ export default function NewSessionDialog({
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
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 = "";
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer transition-colors hover:bg-accent ${group === g.name ? "bg-primary/15 text-primary" : ""}`}
|
||||
onClick={() => {
|
||||
setGroup(g.name);
|
||||
setShowGroupDropdown(false);
|
||||
@@ -323,16 +255,11 @@ export default function NewSessionDialog({
|
||||
{g.name}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="p-1.5 border-t"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--df-border) 60%, transparent)" }}
|
||||
>
|
||||
<div className="p-1.5 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
className="flex-1 min-w-0 h-7 text-xs"
|
||||
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}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -343,9 +270,9 @@ export default function NewSessionDialog({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="flex-shrink-0 p-1 transition-colors disabled:opacity-30"
|
||||
style={{ color: "var(--df-text-muted)" }}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!newGroupName.trim()}
|
||||
onClick={() => {
|
||||
if (newGroupName.trim()) {
|
||||
@@ -356,7 +283,7 @@ export default function NewSessionDialog({
|
||||
}}
|
||||
>
|
||||
<span className="material-icons text-sm">add</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,26 +294,19 @@ export default function NewSessionDialog({
|
||||
{/* Host + Port */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.host")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.host")}</Label>
|
||||
<Input
|
||||
className="mt-1 text-xs h-8"
|
||||
placeholder="192.168.1.100"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.port")}
|
||||
</label>
|
||||
<input
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.port")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
className="mt-1 text-xs h-8"
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
/>
|
||||
@@ -395,13 +315,9 @@ export default function NewSessionDialog({
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.username")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.username")}</Label>
|
||||
<Input
|
||||
className="mt-1 text-xs h-8"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@@ -409,51 +325,34 @@ export default function NewSessionDialog({
|
||||
|
||||
{/* Auth Type */}
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.authentication")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex-1 py-1.5 text-xs rounded border transition-colors"
|
||||
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)",
|
||||
}}
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.authentication")}</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button
|
||||
variant={authType === "password" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setAuthType("password")}
|
||||
>
|
||||
{t("dialog.password")}
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 py-1.5 text-xs rounded border transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
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)",
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
variant={authType === "key" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setAuthType("key")}
|
||||
>
|
||||
{t("dialog.privateKey")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password or Key File */}
|
||||
{authType === "password" ? (
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.password")}
|
||||
</label>
|
||||
<input
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
className="mt-1 text-xs h-8"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
@@ -461,32 +360,18 @@ export default function NewSessionDialog({
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.privateKey")}
|
||||
</label>
|
||||
<div
|
||||
className="flex items-center w-full rounded border overflow-hidden transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--df-bg-input)",
|
||||
borderColor: "var(--df-border)",
|
||||
}}
|
||||
>
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.privateKey")}</Label>
|
||||
<div className="flex items-center w-full rounded-md border overflow-hidden mt-1 bg-transparent">
|
||||
<div
|
||||
className="flex-1 px-3 py-2 text-xs truncate"
|
||||
style={{
|
||||
color: keyFileName || hasKeyData ? "var(--df-text)" : "var(--df-text-muted)",
|
||||
opacity: keyFileName || hasKeyData ? 1 : 0.5,
|
||||
}}
|
||||
className={`flex-1 px-3 py-2 text-xs truncate ${keyFileName || hasKeyData ? "text-foreground" : "text-muted-foreground opacity-50"}`}
|
||||
>
|
||||
{keyFileName || (hasKeyData ? t("dialog.keyFileLoaded") : t("dialog.selectKeyFile"))}
|
||||
</div>
|
||||
<button
|
||||
<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"
|
||||
style={{
|
||||
borderColor: "var(--df-border)",
|
||||
color: "var(--df-text-muted)",
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-none border-l h-auto py-2"
|
||||
onClick={async () => {
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
@@ -501,17 +386,14 @@ export default function NewSessionDialog({
|
||||
}}
|
||||
>
|
||||
<span className="material-icons text-sm">folder_open</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.passphrase")}
|
||||
</label>
|
||||
<input
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.passphrase")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
className="mt-1 text-xs h-8"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
/>
|
||||
@@ -521,14 +403,11 @@ export default function NewSessionDialog({
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--df-text-muted)" }}>
|
||||
{t("dialog.description")}
|
||||
</label>
|
||||
<textarea
|
||||
<Label className="text-[11px] text-muted-foreground">{t("dialog.description")}</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
placeholder={t("dialog.descriptionPlaceholder")}
|
||||
className="w-full rounded px-3 py-2 text-xs border focus:outline-none resize-none transition-colors"
|
||||
style={inputStyle}
|
||||
className="mt-1 text-xs resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
@@ -536,7 +415,7 @@ export default function NewSessionDialog({
|
||||
|
||||
{/* Messages */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -548,27 +427,20 @@ export default function NewSessionDialog({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex justify-end gap-2 px-5 py-3 border-t"
|
||||
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}
|
||||
>
|
||||
<DialogFooter className="px-5 py-3 border-t">
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={handleClose}>
|
||||
{t("dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-1.5 text-xs text-white rounded transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--df-primary)" }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={handleSave}
|
||||
disabled={connecting || !host}
|
||||
>
|
||||
{connecting ? t("dialog.saving") : t("dialog.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 {
|
||||
sessionId: string;
|
||||
@@ -26,12 +34,6 @@ interface PropertiesDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
backgroundColor: "var(--df-bg-input)",
|
||||
borderColor: "var(--df-border)",
|
||||
color: "var(--df-text)",
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
@@ -101,16 +103,6 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
|
||||
return () => { isMounted = false; };
|
||||
}, [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 () => {
|
||||
if (octal === initialOctal) {
|
||||
onClose();
|
||||
@@ -162,94 +154,67 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
|
||||
return data.fullPath.substring(0, idx + 1);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm transition-opacity">
|
||||
<div
|
||||
className="rounded-lg w-[420px] shadow-2xl border flex flex-col"
|
||||
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||
>
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && !isSaving && onClose()}>
|
||||
<DialogContent className="w-[420px] sm:max-w-[420px] p-0 gap-0">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-b select-none"
|
||||
style={{ borderColor: "var(--df-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle className="text-sm flex items-center gap-2">
|
||||
<span className="material-icons text-lg" style={{ color: data.is_dir ? "#eab308" : "var(--df-primary)" }}>
|
||||
{data.is_dir ? 'folder' : 'insert_drive_file'}
|
||||
</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 })}
|
||||
</h2>
|
||||
</div>
|
||||
<span
|
||||
className="material-icons text-base cursor-pointer transition-opacity hover:opacity-70"
|
||||
style={{ color: "var(--df-text-muted)" }}
|
||||
onClick={onClose}
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 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 ? (
|
||||
<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="text-xs">{t("fileExplorer.loading", "Loading...")}</span>
|
||||
</div>
|
||||
) : 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}
|
||||
</div>
|
||||
) : properties ? (
|
||||
<>
|
||||
{/* General Information Section */}
|
||||
{/* General Information */}
|
||||
<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")}
|
||||
</h3>
|
||||
<div className="space-y-2.5 text-xs text-left" style={{ color: "var(--df-text)" }}>
|
||||
<div className="flex items-start">
|
||||
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.type", "Type")}:</span>
|
||||
<span>{getFileType()}</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.location", "Location")}:</span>
|
||||
<span className="truncate break-all select-all font-mono" title={getLocation()}>{getLocation()}</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-24 shrink-0" style={{ color: "var(--df-text-muted)" }}>{t("fileExplorer.size", "Size")}:</span>
|
||||
<span>{formatSize(properties.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<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 className="space-y-2.5 text-xs text-left">
|
||||
{[
|
||||
[t("fileExplorer.type", "Type"), getFileType()],
|
||||
[t("fileExplorer.location", "Location"), <span key="loc" className="truncate break-all select-all font-mono" title={getLocation()}>{getLocation()}</span>],
|
||||
[t("fileExplorer.size", "Size"), formatSize(properties.size)],
|
||||
[t("fileExplorer.mtime", "Modified"), <span key="mt" className="font-mono">{formatTime(properties.mtime)}</span>],
|
||||
[t("fileExplorer.atime", "Accessed"), <span key="at" className="font-mono">{formatTime(properties.atime)}</span>],
|
||||
[t("fileExplorer.owner", "Owner"), <span key="ow">{properties.owner} <span className="font-mono opacity-70">[{properties.uid}]</span></span>],
|
||||
[t("fileExplorer.group", "Group"), <span key="gr">{properties.group} <span className="font-mono opacity-70">[{properties.gid}]</span></span>],
|
||||
].map(([label, value], i) => (
|
||||
<div key={i} className="flex items-start">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{label}:</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" style={{ borderColor: "var(--df-border)" }} />
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Permissions Section */}
|
||||
{/* Permissions */}
|
||||
<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")}
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody style={{ color: "var(--df-text)" }}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<tr className="border-t" style={{ borderColor: "var(--df-border)" }}>
|
||||
<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-[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-[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"><input type="checkbox" className="accent-[var(--df-primary)] cursor-pointer" checked={hasBit(2, 1)} onChange={(e) => updateBit(2, 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, 2)} onChange={(e) => updateBit(0, 2, e.target.checked)} /> GID</label></td>
|
||||
</tr>
|
||||
<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.permOther", "Other")}</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>
|
||||
<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>
|
||||
{[
|
||||
{ label: t("fileExplorer.permUser", "User"), idx: 1, sIdx: 0, sBit: 4, sLabel: "UID", alt: true },
|
||||
{ label: t("fileExplorer.permGroup", "Group"), idx: 2, sIdx: 0, sBit: 2, sLabel: "GID", alt: false },
|
||||
{ label: t("fileExplorer.permOther", "Other"), idx: 3, sIdx: 0, sBit: 1, sLabel: t("fileExplorer.permSticky", "Sticky"), alt: true },
|
||||
].map((row) => (
|
||||
<tr key={row.idx} className={`border-t ${row.alt ? "bg-muted/30" : ""}`}>
|
||||
<td className="px-3 py-2 text-muted-foreground">{row.label}</td>
|
||||
<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-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-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">
|
||||
<label className="flex items-center justify-center gap-1.5 cursor-pointer text-[10px]">
|
||||
<input type="checkbox" className="accent-primary cursor-pointer" checked={hasBit(row.sIdx, row.sBit)} onChange={(e) => updateBit(row.sIdx, row.sBit, e.target.checked)} />
|
||||
{row.sLabel}
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span className="text-xs font-mono mr-2 opacity-50">0</span>
|
||||
<input
|
||||
type="text"
|
||||
className="w-[60px] rounded px-2 py-1 text-center font-mono text-xs border focus:outline-none transition-colors"
|
||||
style={{ ...inputStyle, letterSpacing: '2px' }}
|
||||
<Input
|
||||
className="w-[60px] text-center font-mono text-xs h-7"
|
||||
style={{ letterSpacing: '2px' }}
|
||||
value={octal.substring(1)}
|
||||
onChange={(e) => {
|
||||
let val = e.target.value.replace(/[^0-7]/g, '');
|
||||
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'));
|
||||
}}
|
||||
/>
|
||||
@@ -308,30 +268,16 @@ export default function PropertiesDialog({ data, onClose }: PropertiesDialogProp
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex justify-end gap-2 px-5 py-3 border-t bg-black/5"
|
||||
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}
|
||||
>
|
||||
<DialogFooter className="px-5 py-3 border-t">
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={onClose} disabled={isSaving}>
|
||||
{t("dialog.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" className="text-xs" onClick={handleSave} disabled={isSaving || loading || !!error}>
|
||||
{isSaving && <span className="material-icons text-[14px] animate-spin">refresh</span>}
|
||||
{t("dialog.save", "Save Changes")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
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 {
|
||||
sessionId: string;
|
||||
@@ -25,16 +34,6 @@ export default function RenameDialog({ data, onClose, onSuccess }: RenameDialogP
|
||||
setDialogInput(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 () => {
|
||||
if (!dialogInput || dialogInput === data.name) {
|
||||
onClose();
|
||||
@@ -58,41 +57,33 @@ export default function RenameDialog({ data, onClose, onSuccess }: RenameDialogP
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
<div className="rounded shadow-xl p-4 w-80" style={{ backgroundColor: "var(--df-bg-panel)", border: "1px solid var(--df-border)" }}>
|
||||
<h3 className="font-bold mb-4">{t("fileExplorer.renameTo", { name: data.name })}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full text-sm p-2 rounded border mb-4 focus:outline-none"
|
||||
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
|
||||
value={dialogInput}
|
||||
onChange={(e) => setDialogInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleRenameSubmit()}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
<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}
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && !isSubmitting && onClose()}>
|
||||
<DialogContent className="w-80 sm:max-w-80">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{t("fileExplorer.renameTo", { name: data.name })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t("fileExplorer.renameTo", { name: data.name })}</Label>
|
||||
<Input
|
||||
className="text-sm"
|
||||
value={dialogInput}
|
||||
onChange={(e) => setDialogInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && handleRenameSubmit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
|
||||
{t("dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <span className="material-icons text-[14px] animate-spin">refresh</span>}
|
||||
{t("dialog.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,76 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { useApp } from "../../context/AppContext";
|
||||
import { themeList } from "../../themes";
|
||||
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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -17,19 +87,6 @@ export default function SettingsDialog() {
|
||||
.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 = [
|
||||
{ id: "general", label: t("settings.general", "General"), icon: "settings" },
|
||||
{ id: "appearance", label: t("settings.appearance", "Appearance"), icon: "palette" },
|
||||
@@ -42,45 +99,26 @@ export default function SettingsDialog() {
|
||||
];
|
||||
|
||||
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 }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setShowSettingsDialog(false)}
|
||||
/>
|
||||
|
||||
{/* 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()}
|
||||
<Dialog open={showSettingsDialog} onOpenChange={(v) => !v && setShowSettingsDialog(false)}>
|
||||
<DialogContent
|
||||
className="w-full max-w-4xl sm:max-w-4xl h-[80vh] p-0 gap-0 flex flex-col sm:flex-row overflow-hidden"
|
||||
showCloseButton={false}
|
||||
style={{ fontFamily: appSettings.appearance.font_family }}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className="w-full sm:w-64 flex-shrink-0 flex flex-col border-r overflow-y-auto"
|
||||
style={{ borderColor: "var(--df-border)", backgroundColor: "var(--df-bg)" }}
|
||||
>
|
||||
<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>
|
||||
<div className="w-full sm:w-64 shrink-0 flex flex-col border-r bg-background overflow-y-auto">
|
||||
<div className="p-6 border-b shrink-0 flex items-center gap-3">
|
||||
<span className="material-icons text-2xl text-primary">settings</span>
|
||||
<h2 className="text-xl font-semibold">{t("settings.title", "Settings")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 py-3 px-3 space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={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"
|
||||
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)",
|
||||
}}
|
||||
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"}`}
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
@@ -88,113 +126,84 @@ export default function SettingsDialog() {
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0" style={{ backgroundColor: "var(--df-bg-panel)" }}>
|
||||
<div className="p-6 border-b shrink-0 flex items-center justify-between" style={{ borderColor: "var(--df-border)" }}>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="p-6 border-b shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">
|
||||
{tabs.find(t => t.id === activeTab)?.label}
|
||||
</h3>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
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>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 md:p-8">
|
||||
<div className="max-w-2xl text-base space-y-6">
|
||||
|
||||
{/* ── General Tab ──────────────────────────────────────── */}
|
||||
{activeTab === "general" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.startupRestore", "Restore previous session on startup")}</label>
|
||||
<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>
|
||||
<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.general.startup_restore}
|
||||
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, startup_restore: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.startupRestore", "Restore previous session on startup")}
|
||||
desc={t("settings.startupRestoreDesc", "Automatically reconnect to tabs that were open when you last closed the app.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.general.startup_restore} onChange={(v) => updateAppSettings({ general: { ...appSettings.general, startup_restore: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultLocalShell", "Default Local Shell")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.defaultLocalShellDesc", "The shell path to use when opening a local terminal.")}</p>
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
<SettingInput
|
||||
label={t("settings.defaultLocalShell", "Default Local Shell")}
|
||||
desc={t("settings.defaultLocalShellDesc", "The shell path to use when opening a local terminal.")}
|
||||
value={appSettings.general.default_local_shell}
|
||||
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, default_local_shell: e.target.value } })}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.minimizeToTray", "Minimize to tray on close")}</label>
|
||||
<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>
|
||||
<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.general.minimize_to_tray}
|
||||
onChange={(e) => updateAppSettings({ general: { ...appSettings.general, minimize_to_tray: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.minimizeToTray", "Minimize to tray on close")}
|
||||
desc={t("settings.minimizeToTrayDesc", "Keep the application running in the background when the window is closed.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.general.minimize_to_tray} onChange={(v) => updateAppSettings({ general: { ...appSettings.general, minimize_to_tray: v } })} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Appearance Tab ───────────────────────────────────── */}
|
||||
{activeTab === "appearance" && (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.theme", "Theme")}</label>
|
||||
<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>
|
||||
<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.theme || "github-dark"}
|
||||
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, theme: e.target.value } })}
|
||||
>
|
||||
{themeList.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>{tm.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.theme", "Theme")}
|
||||
desc={t("settings.themeDesc", "Select the color theme for the terminal and application.")}
|
||||
value={appSettings.appearance.theme || "github-dark"}
|
||||
onValueChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, theme: v } })}
|
||||
>
|
||||
{themeList.map((tm) => (
|
||||
<SelectItem key={tm.id} value={tm.id}>{tm.name}</SelectItem>
|
||||
))}
|
||||
</SettingSelect>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.language", "Language")}</label>
|
||||
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.languageDesc", "Select the display language for the application interface.")}</p>
|
||||
<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={uiConfig.language || "en"}
|
||||
onChange={(e) => {
|
||||
const lng = e.target.value;
|
||||
i18n.changeLanguage(lng);
|
||||
updateUiConfig({ language: lng });
|
||||
}}
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(lng => (
|
||||
<option key={lng.id} value={lng.id}>{lng.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.language", "Language")}
|
||||
desc={t("settings.languageDesc", "Select the display language for the application interface.")}
|
||||
value={uiConfig.language || "en"}
|
||||
onValueChange={(lng) => { i18n.changeLanguage(lng); updateUiConfig({ language: lng }); }}
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(lng => (
|
||||
<SelectItem key={lng.id} value={lng.id}>{lng.name}</SelectItem>
|
||||
))}
|
||||
</SettingSelect>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{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>
|
||||
|
||||
<Label className="font-medium text-sm">{t("settings.fontFamily", "Font Family")}</Label>
|
||||
<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">
|
||||
{appSettings.appearance.font_family.split(",").map(f => f.trim()).map((font, idx, arr) => (
|
||||
<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
|
||||
className="flex-1 px-3 py-1.5 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)" }}
|
||||
className="flex-1 h-9 rounded-md border bg-transparent px-3 text-sm shadow-xs focus:outline-none"
|
||||
value={systemFonts.includes(font) ? font : ""}
|
||||
onChange={(e) => {
|
||||
const newFonts = [...arr];
|
||||
@@ -202,243 +211,166 @@ export default function SettingsDialog() {
|
||||
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>)}
|
||||
</select>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title={t("common.remove", "Remove")}
|
||||
onClick={() => {
|
||||
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(", ") } });
|
||||
}}
|
||||
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>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-primary"
|
||||
onClick={() => {
|
||||
const newFonts = [...appSettings.appearance.font_family.split(",").map(f => f.trim()), systemFonts[0] || "Arial"];
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.fontSize", "Font Size (px)")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={8} max={72}
|
||||
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.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 } })}
|
||||
<SettingInput
|
||||
label={t("settings.fontSize", "Font Size (px)")}
|
||||
type="number"
|
||||
min={8} max={72}
|
||||
value={appSettings.appearance.font_size}
|
||||
onChange={(e) => updateAppSettings({ appearance: { ...appSettings.appearance, font_size: parseInt(e.target.value) || 14 } })}
|
||||
/>
|
||||
<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 className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.fontLigatures", "Enable Font Ligatures")}</label>
|
||||
<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.cursorBlink", "Cursor Blink")}>
|
||||
<SettingCheckbox checked={appSettings.appearance.cursor_blink} onChange={(v) => updateAppSettings({ appearance: { ...appSettings.appearance, cursor_blink: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Terminal Tab ─────────────────────────────────────── */}
|
||||
{activeTab === "terminal" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.scrollbackLines", "Scrollback Buffer (lines)")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.scrollbackLinesDesc", "Number of lines kept in memory for scrolling up.")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min={100} max={100000} step={100}
|
||||
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>
|
||||
<SettingInput
|
||||
label={t("settings.scrollbackLines", "Scrollback Buffer (lines)")}
|
||||
desc={t("settings.scrollbackLinesDesc", "Number of lines kept in memory for scrolling up.")}
|
||||
type="number" min={100} max={100000} step={100}
|
||||
value={appSettings.terminal.scrollback_lines}
|
||||
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, scrollback_lines: parseInt(e.target.value) || 5000 } })}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.keepAliveInterval", "Keep-Alive Interval (seconds)")}</label>
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
min={0} max={600} step={5}
|
||||
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>
|
||||
<SettingInput
|
||||
label={t("settings.keepAliveInterval", "Keep-Alive Interval (seconds)")}
|
||||
desc={t("settings.keepAliveIntervalDesc", "Send SSH keep-alive packets every X seconds. 0 to disable.")}
|
||||
type="number" min={0} max={600} step={5}
|
||||
value={appSettings.terminal.keep_alive_interval}
|
||||
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, keep_alive_interval: parseInt(e.target.value) || 0 } })}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.hardwareAcceleration", "Hardware Acceleration")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.hardwareAccelerationDesc", "Use GPU for terminal rendering (WebGL/Canvas). Requires restart.")}</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.terminal.hardware_acceleration}
|
||||
onChange={(e) => updateAppSettings({ terminal: { ...appSettings.terminal, hardware_acceleration: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.hardwareAcceleration", "Hardware Acceleration")}
|
||||
desc={t("settings.hardwareAccelerationDesc", "Use GPU for terminal rendering (WebGL/Canvas). Requires restart.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.terminal.hardware_acceleration} onChange={(v) => updateAppSettings({ terminal: { ...appSettings.terminal, hardware_acceleration: v } })} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Interaction Tab ──────────────────────────────────── */}
|
||||
{activeTab === "interaction" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.copyOnSelect", "Copy on Select")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.copyOnSelectDesc", "Automatically copy selected text to the clipboard.")}</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.interaction.copy_on_select}
|
||||
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, copy_on_select: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.copyOnSelect", "Copy on Select")}
|
||||
desc={t("settings.copyOnSelectDesc", "Automatically copy selected text to the clipboard.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.interaction.copy_on_select} onChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, copy_on_select: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.rightClickPaste", "Right-click Paste")}</label>
|
||||
<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>
|
||||
<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.interaction.right_click_paste}
|
||||
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, right_click_paste: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.rightClickPaste", "Right-click Paste")}
|
||||
desc={t("settings.rightClickPasteDesc", "Paste clipboard content on right-click instead of opening context menu.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.interaction.right_click_paste} onChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, right_click_paste: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.wordSeparators", "Word Separators")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.wordSeparatorsDesc", "Characters that separate words for double-click selection.")}</p>
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
<SettingInput
|
||||
label={t("settings.wordSeparators", "Word Separators")}
|
||||
desc={t("settings.wordSeparatorsDesc", "Characters that separate words for double-click selection.")}
|
||||
value={appSettings.interaction.word_separators}
|
||||
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, word_separators: e.target.value } })}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultEncoding", "Default Encoding")}</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.interaction.default_encoding}
|
||||
onChange={(e) => updateAppSettings({ interaction: { ...appSettings.interaction, default_encoding: e.target.value } })}
|
||||
>
|
||||
<option value="UTF-8">UTF-8</option>
|
||||
<option value="GBK">GBK</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.defaultEncoding", "Default Encoding")}
|
||||
value={appSettings.interaction.default_encoding}
|
||||
onValueChange={(v) => updateAppSettings({ interaction: { ...appSettings.interaction, default_encoding: v } })}
|
||||
>
|
||||
<SelectItem value="UTF-8">UTF-8</SelectItem>
|
||||
<SelectItem value="GBK">GBK</SelectItem>
|
||||
</SettingSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Proxy Tab ────────────────────────────────────────── */}
|
||||
{activeTab === "proxy" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.enableProxy", "Enable Proxy")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.enableProxyDesc", "Route SSH connections through a proxy server.")}</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.proxy.enabled}
|
||||
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, enabled: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.enableProxy", "Enable Proxy")}
|
||||
desc={t("settings.enableProxyDesc", "Route SSH connections through a proxy server.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.proxy.enabled} onChange={(v) => updateAppSettings({ proxy: { ...appSettings.proxy, enabled: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<div className={`space-y-4 ${!appSettings.proxy.enabled ? "opacity-50 pointer-events-none" : ""}`}>
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyProtocol", "Protocol")}</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.proxy.protocol}
|
||||
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, protocol: e.target.value } })}
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.proxyProtocol", "Protocol")}
|
||||
value={appSettings.proxy.protocol}
|
||||
onValueChange={(v) => updateAppSettings({ proxy: { ...appSettings.proxy, protocol: v } })}
|
||||
>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-1 flex-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyHost", "Host")}</label>
|
||||
<input
|
||||
type="text"
|
||||
<div className="flex-1">
|
||||
<SettingInput
|
||||
label={t("settings.proxyHost", "Host")}
|
||||
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}
|
||||
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, host: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-24">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.proxyPort", "Port")}</label>
|
||||
<input
|
||||
type="number"
|
||||
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)" }}
|
||||
<div className="w-24">
|
||||
<SettingInput
|
||||
label={t("settings.proxyPort", "Port")}
|
||||
type="number" min={1} max={65535}
|
||||
value={appSettings.proxy.port || ""}
|
||||
onChange={(e) => updateAppSettings({ proxy: { ...appSettings.proxy, port: parseInt(e.target.value) || 0 } })}
|
||||
/>
|
||||
@@ -448,46 +380,42 @@ export default function SettingsDialog() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search Tab ───────────────────────────────────────── */}
|
||||
{activeTab === "search" && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.defaultSearchEngine", "Default Search Engine")}</label>
|
||||
<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>
|
||||
<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.search.default_engine}
|
||||
onChange={(e) => updateAppSettings({ search: { ...appSettings.search, default_engine: e.target.value } })}
|
||||
>
|
||||
{appSettings.search.custom_engines.map((engine, idx) => (
|
||||
<option key={idx} value={engine.name}>{engine.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.defaultSearchEngine", "Default Search Engine")}
|
||||
desc={t("settings.defaultSearchEngineDesc", "The primary engine used when double-clicking or right-clicking to search.")}
|
||||
value={appSettings.search.default_engine}
|
||||
onValueChange={(v) => updateAppSettings({ search: { ...appSettings.search, default_engine: v } })}
|
||||
>
|
||||
{appSettings.search.custom_engines.map((engine, idx) => (
|
||||
<SelectItem key={idx} value={engine.name}>{engine.name}</SelectItem>
|
||||
))}
|
||||
</SettingSelect>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.customEngines", "Search Engines")}</label>
|
||||
<button
|
||||
<Label className="font-medium text-sm">{t("settings.customEngines", "Search Engines")}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-primary"
|
||||
onClick={() => {
|
||||
const newEngines = [...appSettings.search.custom_engines, { name: "New Engine", url_template: "https://example.com/search?q=%s" }];
|
||||
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")}
|
||||
</button>
|
||||
</Button>
|
||||
</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) => (
|
||||
<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)" }}>
|
||||
<input
|
||||
type="text"
|
||||
<div key={i} className="flex items-center gap-2 p-2 border-b last:border-0 hover:bg-accent transition-colors">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="w-1/3 px-2 py-1.5 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)" }}
|
||||
className="w-1/3 text-sm"
|
||||
value={engine.name}
|
||||
onChange={(e) => {
|
||||
const newEngines = [...appSettings.search.custom_engines];
|
||||
@@ -495,11 +423,9 @@ export default function SettingsDialog() {
|
||||
updateAppSettings({ search: { ...appSettings.search, custom_engines: newEngines } });
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
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"
|
||||
style={{ backgroundColor: "var(--df-bg)", borderColor: "var(--df-border)", color: "var(--df-text)" }}
|
||||
className="flex-1 text-sm"
|
||||
value={engine.url_template}
|
||||
onChange={(e) => {
|
||||
const newEngines = [...appSettings.search.custom_engines];
|
||||
@@ -507,151 +433,117 @@ export default function SettingsDialog() {
|
||||
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={() => {
|
||||
const newEngines = appSettings.search.custom_engines.filter((_, idx) => idx !== i);
|
||||
const newDefault = (appSettings.search.default_engine === engine.name)
|
||||
? (newEngines[0]?.name || "")
|
||||
: appSettings.search.default_engine;
|
||||
const newDefault = (appSettings.search.default_engine === engine.name) ? (newEngines[0]?.name || "") : appSettings.search.default_engine;
|
||||
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>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{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.")}
|
||||
</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.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Translation Tab ──────────────────────────────────── */}
|
||||
{activeTab === "translation" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.translationProvider", "Translation Provider")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.translationProviderDesc", "Select the API provider for translating terminal output.")}</p>
|
||||
<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.translation.provider}
|
||||
onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, provider: e.target.value } })}
|
||||
>
|
||||
<option value="">{t("settings.translationDisabled", "Disabled")}</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepl">DeepL</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label={t("settings.translationProvider", "Translation Provider")}
|
||||
desc={t("settings.translationProviderDesc", "Select the API provider for translating terminal output.")}
|
||||
value={appSettings.translation.provider}
|
||||
onValueChange={(v) => updateAppSettings({ translation: { ...appSettings.translation, provider: v } })}
|
||||
>
|
||||
<SelectItem value="">{t("settings.translationDisabled", "Disabled")}</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="deepl">DeepL</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
{appSettings.translation.provider !== "" && (
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.translationApiKey", "API Key")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.translationApiKeyDesc", "Enter the API key for your chosen translation provider.")}</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
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.translation.api_key}
|
||||
onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, api_key: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingInput
|
||||
label={t("settings.translationApiKey", "API Key")}
|
||||
desc={t("settings.translationApiKeyDesc", "Enter the API key for your chosen translation provider.")}
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={appSettings.translation.api_key}
|
||||
onChange={(e) => updateAppSettings({ translation: { ...appSettings.translation, api_key: e.target.value } })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Security Tab ─────────────────────────────────────── */}
|
||||
{activeTab === "security" && (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.useOsKeyring", "Use OS Keyring")}</label>
|
||||
<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>
|
||||
<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.security.use_os_keyring}
|
||||
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, use_os_keyring: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.useOsKeyring", "Use OS Keyring")}
|
||||
desc={t("settings.useOsKeyringDesc", "Securely store SSH passwords and keys in your system's native keychain.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.security.use_os_keyring} onChange={(v) => updateAppSettings({ security: { ...appSettings.security, use_os_keyring: v } })} />
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.requireMasterPassword", "Require Master Password")}</label>
|
||||
<p className="text-xs" style={{ color: "var(--df-text-muted)" }}>{t("settings.requireMasterPasswordDesc", "Require a master password to encrypt your session database.")}</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.security.require_master_password}
|
||||
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, require_master_password: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("settings.requireMasterPassword", "Require Master Password")}
|
||||
desc={t("settings.requireMasterPasswordDesc", "Require a master password to encrypt your session database.")}
|
||||
>
|
||||
<SettingCheckbox checked={appSettings.security.require_master_password} onChange={(v) => updateAppSettings({ security: { ...appSettings.security, require_master_password: v } })} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 space-y-4" style={{ borderColor: "var(--df-border)" }}>
|
||||
<h4 className="font-semibold text-sm" style={{ color: "var(--df-text)" }}>{t("settings.sessionSecurity", "Session Security")}</h4>
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<h4 className="font-semibold text-sm">{t("settings.sessionSecurity", "Session Security")}</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.idleLockMinutes", "Session Lock Interval")}</label>
|
||||
<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>
|
||||
<SettingRow
|
||||
label={t("settings.idleLockMinutes", "Session Lock Interval")}
|
||||
desc={t("settings.idleLockMinutesDesc", "Lock the application after a specified duration of inactivity (0 to disable).")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0} max={1440}
|
||||
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)" }}
|
||||
<Input
|
||||
type="number" min={0} max={1440}
|
||||
className="w-20 text-sm"
|
||||
value={appSettings.security.idle_lock_minutes}
|
||||
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>
|
||||
</SettingRow>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-sm" style={{ color: "var(--df-text)" }}>{t("settings.hostKeyPolicy", "Host Key Policy")}</label>
|
||||
<p className="text-xs pb-1" style={{ color: "var(--df-text-muted)" }}>{t("settings.hostKeyPolicyDesc", "How the application handles unrecognized SSH host keys.")}</p>
|
||||
<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.security.host_key_policy}
|
||||
onChange={(e) => updateAppSettings({ security: { ...appSettings.security, host_key_policy: e.target.value } })}
|
||||
>
|
||||
<option value="strict">{t("settings.hostKeyStrict", "Strict (Reject unknown hosts)")}</option>
|
||||
<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>
|
||||
<SettingSelect
|
||||
label={t("settings.hostKeyPolicy", "Host Key Policy")}
|
||||
desc={t("settings.hostKeyPolicyDesc", "How the application handles unrecognized SSH host keys.")}
|
||||
value={appSettings.security.host_key_policy}
|
||||
onValueChange={(v) => updateAppSettings({ security: { ...appSettings.security, host_key_policy: v } })}
|
||||
>
|
||||
<SelectItem value="strict">{t("settings.hostKeyStrict", "Strict (Reject unknown hosts)")}</SelectItem>
|
||||
<SelectItem value="prompt">{t("settings.hostKeyPrompt", "Prompt (Ask user for confirmation)")}</SelectItem>
|
||||
<SelectItem value="accept">{t("settings.hostKeyAccept", "Accept (Automatically add new hosts)")}</SelectItem>
|
||||
</SettingSelect>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useApp } from "../../context/AppContext";
|
||||
import { invoke } from "../../lib/invoke";
|
||||
import { logger } from "../../lib/logger";
|
||||
import type { Group, SavedConnection, SessionType } from "../../types";
|
||||
import { useToast } from "../toast/ToastContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SavedConnectionsProps {
|
||||
onEditConnection: (connection: SavedConnection) => void;
|
||||
@@ -27,7 +27,7 @@ export default function SavedConnections({
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
// Build grouped view: use savedGroups order, include empty groups
|
||||
const { groups, ungrouped } = useMemo(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import PropertiesDialog, { PropertiesDialogData } from "../dialog/PropertiesDial
|
||||
import RenameDialog, { RenameDialogData } from "../dialog/RenameDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useApp } from "../../context/AppContext";
|
||||
import { useToast } from "../toast/ToastContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
@@ -154,7 +154,7 @@ function formatSize(bytes: number): string {
|
||||
export default function FileExplorer({ activeSessionId }: FileExplorerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showContextMenu } = useApp();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "JetBrains Mono", "Inter", sans-serif;
|
||||
--font-display: "JetBrains Mono", "Inter", sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
@@ -8,6 +12,70 @@
|
||||
--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 ──────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -53,6 +121,7 @@ body,
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────────────────────── */
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
@@ -91,3 +160,13 @@ body,
|
||||
.df-hover-primary:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -18,7 +18,13 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
||||
+10
-4
@@ -1,3 +1,4 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -8,6 +9,11 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
@@ -16,10 +22,10 @@ export default defineConfig(async () => ({
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
|
||||
Reference in new issue
Block a user