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