style: shadcn ui

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

No files matched your search

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