diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f64eb142d6f..10f550ccfe0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -64,6 +64,7 @@ "line-column-path": "^3.0.0", "lodash": "^4.17.21", "lowdb": "^7.0.1", + "lucide-react": "^0.555.0", "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index d8a51bbe3c8..ca60519ef2e 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1,15 +1,85 @@ -import { basename } from "node:path"; +import { existsSync } from "node:fs"; +import { access } from "node:fs/promises"; +import { basename, join } from "node:path"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { db } from "main/lib/db"; import type { Project } from "main/lib/db/schemas"; import { nanoid } from "nanoid"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; +import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getGitRoot } from "../workspaces/utils/git"; import { assignRandomColor } from "./utils/colors"; +// Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode +// Allows most valid Git repo names while avoiding path traversal characters +const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/; + +/** + * Extracts and validates a repository name from a git URL. + * Handles HTTP/HTTPS URLs, SSH-style URLs (git@host:user/repo), and edge cases. + */ +function extractRepoName(urlInput: string): string | null { + // Normalize: trim whitespace and strip trailing slashes + let normalized = urlInput.trim().replace(/\/+$/, ""); + + if (!normalized) return null; + + let repoSegment: string | undefined; + + // Try parsing as HTTP/HTTPS URL first + try { + const parsed = new URL(normalized); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + // Get pathname and strip query/hash (URL constructor handles this) + const pathname = parsed.pathname; + // Get the last segment of the path + repoSegment = pathname.split("/").filter(Boolean).pop(); + } + } catch { + // Not a valid URL, try SSH-style parsing + } + + // Fallback to SSH-style parsing (git@github.com:user/repo.git) + if (!repoSegment) { + // Handle SSH format: git@host:path or just path segments + const colonIndex = normalized.indexOf(":"); + if (colonIndex !== -1 && !normalized.includes("://")) { + // SSH-style: take everything after the colon + normalized = normalized.slice(colonIndex + 1); + } + // Split by '/' and get the last segment + repoSegment = normalized.split("/").filter(Boolean).pop(); + } + + if (!repoSegment) return null; + + // Strip query string and hash if present (for edge cases) + repoSegment = repoSegment.split("?")[0].split("#")[0]; + + // Remove trailing .git extension + repoSegment = repoSegment.replace(/\.git$/, ""); + + // Decode percent-encoded characters + try { + repoSegment = decodeURIComponent(repoSegment); + } catch { + // Invalid encoding, continue with raw value + } + + // Trim any remaining whitespace or special characters at boundaries + repoSegment = repoSegment.trim(); + + // Validate against safe filename regex + if (!repoSegment || !SAFE_REPO_NAME_REGEX.test(repoSegment)) { + return null; + } + + return repoSegment; +} + export const createProjectsRouter = (window: BrowserWindow) => { return router({ getRecents: publicProcedure.query((): Project[] => { @@ -73,6 +143,129 @@ export const createProjectsRouter = (window: BrowserWindow) => { }; }), + cloneRepo: publicProcedure + .input( + z.object({ + url: z.string().url(), + // Trim and convert empty/whitespace strings to undefined + targetDirectory: z + .string() + .trim() + .optional() + .transform((v) => (v && v.length > 0 ? v : undefined)), + }), + ) + .mutation(async ({ input }) => { + try { + let targetDir = input.targetDirectory; + + if (!targetDir) { + const result = await dialog.showOpenDialog(window, { + properties: ["openDirectory", "createDirectory"], + title: "Select Clone Destination", + }); + + // User canceled - return canceled state (not an error) + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true as const, success: false as const }; + } + + targetDir = result.filePaths[0]; + } + + const repoName = extractRepoName(input.url); + if (!repoName) { + return { + canceled: false as const, + success: false as const, + error: "Invalid repository URL", + }; + } + + const clonePath = join(targetDir, repoName); + + // Check if we already have a project for this path + const existingProject = db.data.projects.find( + (p) => p.mainRepoPath === clonePath, + ); + + if (existingProject) { + // Verify the filesystem path still exists + try { + await access(clonePath); + // Directory exists - update lastOpenedAt and return existing project + await db.update((data) => { + const p = data.projects.find( + (p) => p.id === existingProject.id, + ); + if (p) { + p.lastOpenedAt = Date.now(); + } + }); + return { + canceled: false as const, + success: true as const, + project: existingProject, + }; + } catch { + // Directory is missing - remove the stale project record and continue with clone + await db.update((data) => { + const index = data.projects.findIndex( + (p) => p.id === existingProject.id, + ); + if (index !== -1) { + data.projects.splice(index, 1); + } + }); + // Continue to normal creation flow below + } + } + + // Check if target directory already exists (but not our project) + if (existsSync(clonePath)) { + return { + canceled: false as const, + success: false as const, + error: `A folder named "${repoName}" already exists at this location. Please choose a different destination.`, + }; + } + + // Clone the repository + const git = simpleGit(); + await git.clone(input.url, clonePath); + + // Create new project + const name = basename(clonePath); + const project: Project = { + id: nanoid(), + mainRepoPath: clonePath, + name, + color: assignRandomColor(), + tabOrder: null, + lastOpenedAt: Date.now(), + createdAt: Date.now(), + }; + + await db.update((data) => { + data.projects.push(project); + }); + + return { + canceled: false as const, + success: true as const, + project, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + canceled: false as const, + success: false as const, + error: `Failed to clone repository: ${errorMessage}`, + }; + } + }), + update: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index dc19ec73965..2c4d4fe5ee2 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import type { BrowserWindow } from "electron"; import { publicProcedure, router } from ".."; @@ -33,6 +34,10 @@ export const createWindowRouter = (window: BrowserWindow) => { getPlatform: publicProcedure.query(() => { return process.platform; }), + + getHomeDir: publicProcedure.query(() => { + return homedir(); + }), }); }; diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 867494fdc06..43c760ab6ec 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -20,6 +20,8 @@ export async function MainWindow() { title: productName, width, height, + minWidth: 400, + minHeight: 400, show: false, center: true, movable: true, diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx new file mode 100644 index 00000000000..fc9fa2c6168 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/KeyboardShortcutsSettings.tsx @@ -0,0 +1,149 @@ +import { Input } from "@superset/ui/input"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { useMemo, useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; +import { + formatKeysForDisplay, + getHotkeysByCategory, + type HotkeyCategory, + type HotkeyDefinition, +} from "shared/hotkeys"; + +function useIsMac(): boolean { + return useMemo(() => { + const platform = navigator.platform?.toUpperCase() ?? ""; + const userAgent = navigator.userAgent?.toUpperCase() ?? ""; + return platform.includes("MAC") || userAgent.includes("MAC"); + }, []); +} + +const CATEGORY_ORDER: HotkeyCategory[] = [ + "Workspace", + "Terminal", + "Layout", + "Window", + "Help", +]; + +function HotkeyRow({ + hotkey, + isEven, +}: { + hotkey: HotkeyDefinition; + isEven: boolean; +}) { + const keys = formatKeysForDisplay(hotkey.keys); + + return ( +
+ {hotkey.label} + + {keys.map((key) => ( + {key} + ))} + +
+ ); +} + +/** + * Consolidate individual workspace jump shortcuts (1-9) into a single entry + */ +function consolidateWorkspaceJumps( + hotkeys: HotkeyDefinition[], +): HotkeyDefinition[] { + const workspaceJumpPattern = /^Switch to Workspace \d$/; + const hasWorkspaceJumps = hotkeys.some((h) => + workspaceJumpPattern.test(h.label), + ); + + if (!hasWorkspaceJumps) return hotkeys; + + const filtered = hotkeys.filter((h) => !workspaceJumpPattern.test(h.label)); + filtered.unshift({ + keys: "meta+1-9", + label: "Switch to Workspace 1-9", + category: "Workspace", + }); + + return filtered; +} + +export function KeyboardShortcutsSettings() { + const [searchQuery, setSearchQuery] = useState(""); + const hotkeysByCategory = getHotkeysByCategory(); + const isMac = useIsMac(); + const modifierKey = isMac ? "⌘" : "Ctrl"; + + // Flatten and consolidate all hotkeys + const allHotkeys = CATEGORY_ORDER.flatMap((category) => + consolidateWorkspaceJumps(hotkeysByCategory[category]), + ); + + // Filter based on search query + const filteredHotkeys = searchQuery + ? allHotkeys.filter((hotkey) => + hotkey.label.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : allHotkeys; + + return ( +
+ {/* Header */} +
+

Keyboard Shortcuts

+

+ View all available keyboard shortcuts. Press{" "} + {modifierKey} + ? to open this page anytime. +

+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 bg-accent/30 border-transparent focus:border-accent" + /> +
+ + {/* Table */} +
+ {/* Table Header */} +
+ + Command + + + Shortcut + +
+ + {/* Table Body */} +
+ {filteredHotkeys.length > 0 ? ( + filteredHotkeys.map((hotkey, index) => ( + + )) + ) : ( +
+ No shortcuts found matching "{searchQuery}" +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx index a3d4e4f4700..812e0b84eeb 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx @@ -1,5 +1,6 @@ +import type { SettingsSection } from "renderer/stores"; import { AppearanceSettings } from "./AppearanceSettings"; -import type { SettingsSection } from "./index"; +import { KeyboardShortcutsSettings } from "./KeyboardShortcutsSettings"; interface SettingsContentProps { activeSection: SettingsSection; @@ -7,8 +8,9 @@ interface SettingsContentProps { export function SettingsContent({ activeSection }: SettingsContentProps) { return ( -
+
{activeSection === "appearance" && } + {activeSection === "keyboard" && }
); } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx index b660e168f78..0bde8d42e11 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar.tsx @@ -1,7 +1,10 @@ import { cn } from "@superset/ui/utils"; -import { HiArrowLeft, HiOutlinePaintBrush } from "react-icons/hi2"; -import { useCloseSettings } from "renderer/stores"; -import type { SettingsSection } from "./index"; +import { + HiArrowLeft, + HiOutlineCommandLine, + HiOutlinePaintBrush, +} from "react-icons/hi2"; +import { type SettingsSection, useCloseSettings } from "renderer/stores"; interface SettingsSidebarProps { activeSection: SettingsSection; @@ -18,6 +21,11 @@ const SECTIONS: { label: "Appearance", icon: , }, + { + id: "keyboard", + label: "Keyboard Shortcuts", + icon: , + }, ]; export function SettingsSidebar({ diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/index.tsx index 1e176c03f44..69f02869f33 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/index.tsx @@ -1,12 +1,13 @@ -import { useState } from "react"; +import { + useSetSettingsSection, + useSettingsSection, +} from "renderer/stores/app-state"; import { SettingsContent } from "./SettingsContent"; import { SettingsSidebar } from "./SettingsSidebar"; -export type SettingsSection = "appearance"; - export function SettingsView() { - const [activeSection, setActiveSection] = - useState("appearance"); + const activeSection = useSettingsSection(); + const setActiveSection = useSetSettingsSection(); return (
diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/ActionCard.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/ActionCard.tsx new file mode 100644 index 00000000000..0510eb65dc5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StartView/ActionCard.tsx @@ -0,0 +1,39 @@ +import type { LucideIcon } from "lucide-react"; + +interface ActionCardProps { + icon: LucideIcon; + label: string; + onClick?: () => void; + disabled?: boolean; + isLoading?: boolean; +} + +export function ActionCard({ + icon: Icon, + label, + onClick, + disabled = false, + isLoading = false, +}: ActionCardProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx new file mode 100644 index 00000000000..2c0deffab63 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; + +interface CloneRepoDialogProps { + isOpen: boolean; + onClose: () => void; + onError: (error: string) => void; +} + +export function CloneRepoDialog({ + isOpen, + onClose, + onError, +}: CloneRepoDialogProps) { + const [url, setUrl] = useState(""); + const utils = trpc.useUtils(); + const cloneRepo = trpc.projects.cloneRepo.useMutation(); + const createWorkspace = useCreateWorkspace(); + + const handleClone = async () => { + if (!url.trim()) { + onError("Please enter a repository URL"); + return; + } + + cloneRepo.mutate( + { url: url.trim() }, + { + onSuccess: (result) => { + // User canceled the directory picker - silent no-op + if (result.canceled) { + return; + } + + if (result.success && result.project) { + // Invalidate recents so the new/updated project appears + utils.projects.getRecents.invalidate(); + createWorkspace.mutate({ projectId: result.project.id }); + onClose(); + setUrl(""); + } else if (!result.success) { + // Show user-friendly error message + onError(result.error ?? "Failed to clone repository"); + } + }, + onError: (err) => { + onError(err.message || "Failed to clone repository"); + }, + }, + ); + }; + + if (!isOpen) return null; + + const isLoading = cloneRepo.isPending || createWorkspace.isPending; + + return ( +
+
+

+ Clone Repository +

+ +
+
+ + setUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="w-full px-3 py-2.5 bg-background border border-border rounded-md text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:border-ring transition-colors" + disabled={isLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + handleClone(); + } + }} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx new file mode 100644 index 00000000000..140c1ad564c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx @@ -0,0 +1,29 @@ +import { trpc } from "renderer/lib/trpc"; +import { SettingsButton } from "../TopBar/SettingsButton"; +import { WindowControls } from "../TopBar/WindowControls"; + +export function StartTopBar() { + const { data: platform, isLoading } = trpc.window.getPlatform.useQuery(); + const isMac = !isLoading && platform === "darwin"; + const showWindowControls = !isLoading && !isMac; + + return ( +
+
+ {/* Empty space on left for symmetry */} +
+
+ {/* Empty middle section - no tabs */} +
+
+ + {showWindowControls && } +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx new file mode 100644 index 00000000000..d5ea4d96271 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -0,0 +1,253 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { ChevronUp, FolderGit, FolderOpen, X } from "lucide-react"; +import { useState } from "react"; +import { HiExclamationTriangle } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { ActionCard } from "./ActionCard"; +import { CloneRepoDialog } from "./CloneRepoDialog"; +import { StartTopBar } from "./StartTopBar"; + +/** + * Normalizes path separators to forward slashes for consistent handling + */ +function normalizeSeparators(path: string): string { + return path.replace(/\\/g, "/"); +} + +/** + * Formats a path for display, replacing the home directory with ~ and optionally + * removing the trailing project name directory. + * Handles both Unix and Windows paths. + */ +function formatPath( + path: string, + projectName: string, + homeDir: string | undefined, +): { display: string; full: string } { + // Normalize both path and homeDir to use forward slashes + const normalizedPath = normalizeSeparators(path); + const normalizedHome = homeDir ? normalizeSeparators(homeDir) : null; + + // Replace home directory with ~ if we know the home dir + let fullPath = normalizedPath; + if (normalizedHome && normalizedPath.startsWith(normalizedHome)) { + fullPath = `~${normalizedPath.slice(normalizedHome.length)}`; + } else { + // Fallback: try common Unix patterns if home dir not available + fullPath = normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); + } + + // Escape special regex characters in project name + const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const suffixPattern = new RegExp(`/${escapedProjectName}$`); + + // Remove trailing project name directory if it matches + const displayPath = fullPath.replace(suffixPattern, ""); + + return { display: displayPath, full: fullPath }; +} + +export function StartView() { + const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); + const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + const openNew = useOpenNew(); + const createWorkspace = useCreateWorkspace(); + const [error, setError] = useState(null); + const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); + const [showAllProjects, setShowAllProjects] = useState(false); + const [visibleCount, setVisibleCount] = useState(50); + + const handleOpenProject = () => { + setError(null); + openNew.mutate(undefined, { + onSuccess: (result) => { + if (!result.canceled && result.project) { + createWorkspace.mutate({ projectId: result.project.id }); + } + }, + onError: (err) => { + setError(err.message || "Failed to open project"); + }, + }); + }; + + const handleOpenRecentProject = (projectId: string) => { + setError(null); + createWorkspace.mutate( + { projectId }, + { + onError: (err) => { + setError(err.message || "Failed to create workspace"); + }, + }, + ); + }; + + const hasMoreProjects = recentProjects.length > 5; + const displayedProjects = showAllProjects + ? recentProjects.slice(0, visibleCount) + : recentProjects.slice(0, 5); + const hasMoreToLoad = showAllProjects && recentProjects.length > visibleCount; + const isLoading = openNew.isPending || createWorkspace.isPending; + + return ( +
+ +
+
+ {/* Logo */} +
+ + Superset + + +
+ + {/* Error Display */} + {error && ( +
+
+
+ +
+

{error}

+ +
+
+ )} + + {/* Action Cards and Recent Projects Container */} +
+ {/* Action Cards */} +
+ + + { + setError(null); + setIsCloneDialogOpen(true); + }} + isLoading={isLoading} + /> +
+ + {/* Recent Projects */} + {displayedProjects.length > 0 && ( +
+
+
+ + Recent projects + + {hasMoreProjects && ( + + )} +
+ +
+ {displayedProjects.map((project) => { + const pathInfo = formatPath( + project.mainRepoPath, + project.name, + homeDir, + ); + return ( + + ); + })} + + {hasMoreToLoad && ( + + )} +
+
+
+ )} +
+
+
+ + {/* Dialogs */} + setIsCloneDialogOpen(false)} + onError={setError} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/SettingsButton/SettingsButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/SettingsButton/SettingsButton.tsx index 86265ae163b..d3dbacd3ff4 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/SettingsButton/SettingsButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/SettingsButton/SettingsButton.tsx @@ -7,7 +7,7 @@ export function SettingsButton() { return ( +
+ + + ); + } + return ( -
- -
- {currentView === "settings" ? : } + {showStartView ? ( + + ) : ( +
+ +
{renderContent()}
-
+ )} - ); } diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts index d8f6093c8f2..af5aa389512 100644 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ b/apps/desktop/src/renderer/stores/app-state.ts @@ -2,30 +2,41 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; export type AppView = "workspace" | "settings"; +export type SettingsSection = "appearance" | "keyboard"; interface AppState { currentView: AppView; + settingsSection: SettingsSection; setView: (view: AppView) => void; - openSettings: () => void; + openSettings: (section?: SettingsSection) => void; closeSettings: () => void; + setSettingsSection: (section: SettingsSection) => void; } export const useAppStore = create()( devtools( (set) => ({ currentView: "workspace", + settingsSection: "appearance", setView: (view) => { set({ currentView: view }); }, - openSettings: () => { - set({ currentView: "settings" }); + openSettings: (section) => { + set({ + currentView: "settings", + ...(section && { settingsSection: section }), + }); }, closeSettings: () => { set({ currentView: "workspace" }); }, + + setSettingsSection: (section) => { + set({ settingsSection: section }); + }, }), { name: "AppStore" }, ), @@ -33,6 +44,10 @@ export const useAppStore = create()( // Convenience hooks export const useCurrentView = () => useAppStore((state) => state.currentView); +export const useSettingsSection = () => + useAppStore((state) => state.settingsSection); +export const useSetSettingsSection = () => + useAppStore((state) => state.setSettingsSection); export const useOpenSettings = () => useAppStore((state) => state.openSettings); export const useCloseSettings = () => useAppStore((state) => state.closeSettings); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index 07b991429e6..b342947c9a9 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -91,13 +91,13 @@ export const HOTKEYS = { }, SPLIT_HORIZONTAL: { keys: "meta+d", - label: "Split Horizontal", + label: "Split Terminal Window Horizontal", category: "Layout", description: "Create a horizontal split view", }, SPLIT_VERTICAL: { keys: "meta+shift+d", - label: "Split Vertical", + label: "Split Terminal Window Vertical", category: "Layout", description: "Create a vertical split view", }, diff --git a/apps/desktop/src/shared/themes/built-in/dark.ts b/apps/desktop/src/shared/themes/built-in/dark.ts index ee29a4648f0..e7ca2764008 100644 --- a/apps/desktop/src/shared/themes/built-in/dark.ts +++ b/apps/desktop/src/shared/themes/built-in/dark.ts @@ -13,9 +13,9 @@ export const darkTheme: Theme = { ui: { background: "oklch(0.145 0 0)", foreground: "oklch(0.985 0 0)", - card: "oklch(0.145 0 0)", + card: "oklch(0.205 0 0)", cardForeground: "oklch(0.985 0 0)", - popover: "oklch(0.145 0 0)", + popover: "oklch(0.205 0 0)", popoverForeground: "oklch(0.985 0 0)", primary: "oklch(0.985 0 0)", primaryForeground: "oklch(0.205 0 0)", diff --git a/apps/desktop/src/shared/themes/built-in/ember.ts b/apps/desktop/src/shared/themes/built-in/ember.ts new file mode 100644 index 00000000000..122606a631f --- /dev/null +++ b/apps/desktop/src/shared/themes/built-in/ember.ts @@ -0,0 +1,97 @@ +import type { Theme } from "../types"; + +/** + * Ember theme - Warm dark theme inspired by the Figma start screen design + * Features a warm, slightly reddish dark background (#151110) + */ +export const emberTheme: Theme = { + id: "ember", + name: "Ember", + author: "Superset", + type: "dark", + isBuiltIn: true, + + ui: { + // Core - warm dark tones + background: "#151110", + foreground: "#eae8e6", + card: "#201E1C", + cardForeground: "#eae8e6", + popover: "#201E1C", + popoverForeground: "#eae8e6", + + // Primary - light foreground for contrast + primary: "#eae8e6", + primaryForeground: "#151110", + + // Secondary - warm grays + secondary: "#2a2827", + secondaryForeground: "#eae8e6", + + // Muted - subtle warm grays + muted: "#2a2827", + mutedForeground: "#a8a5a3", + + // Accent - warm highlight + accent: "#2a2827", + accentForeground: "#eae8e6", + + // Tertiary - panel backgrounds + tertiary: "#1a1716", + tertiaryActive: "#252220", + + // Destructive - warm red + destructive: "#cc4444", + destructiveForeground: "#ffcccc", + + // Borders - subtle warm gray + border: "#2a2827", + input: "#2a2827", + ring: "#3a3837", + + // Sidebar - slightly lighter than background + sidebar: "#1a1716", + sidebarForeground: "#eae8e6", + sidebarPrimary: "#e07850", + sidebarPrimaryForeground: "#151110", + sidebarAccent: "#252220", + sidebarAccentForeground: "#eae8e6", + sidebarBorder: "#2a2827", + sidebarRing: "#3a3837", + + // Charts - warm palette + chart1: "#e07850", + chart2: "#50a878", + chart3: "#d4a84b", + chart4: "#7b68ee", + chart5: "#dc6b6b", + }, + + terminal: { + background: "#151110", + foreground: "#eae8e6", + cursor: "#e07850", + cursorAccent: "#151110", + selectionBackground: "rgba(224, 120, 80, 0.25)", + + // Standard ANSI colors - warm tinted + black: "#151110", + red: "#dc6b6b", + green: "#7ec699", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#eae8e6", + + // Bright ANSI colors + brightBlack: "#5c5856", + brightRed: "#e88888", + brightGreen: "#98d1a8", + brightYellow: "#ecd08f", + brightBlue: "#7ec0f5", + brightMagenta: "#d494e6", + brightCyan: "#73c7d3", + brightWhite: "#ffffff", + }, +}; diff --git a/apps/desktop/src/shared/themes/built-in/index.ts b/apps/desktop/src/shared/themes/built-in/index.ts index 0e67cdc5811..e2772389d19 100644 --- a/apps/desktop/src/shared/themes/built-in/index.ts +++ b/apps/desktop/src/shared/themes/built-in/index.ts @@ -1,5 +1,6 @@ import type { Theme } from "../types"; import { darkTheme } from "./dark"; +import { emberTheme } from "./ember"; import { lightTheme } from "./light"; import { monokaiTheme } from "./monokai"; import { oneDarkTheme } from "./one-dark"; @@ -10,6 +11,7 @@ import { oneDarkTheme } from "./one-dark"; export const builtInThemes: Theme[] = [ darkTheme, lightTheme, + emberTheme, monokaiTheme, oneDarkTheme, ]; @@ -27,4 +29,4 @@ export function getBuiltInTheme(id: string): Theme | undefined { } // Re-export individual themes -export { darkTheme, lightTheme, monokaiTheme, oneDarkTheme }; +export { darkTheme, emberTheme, lightTheme, monokaiTheme, oneDarkTheme }; diff --git a/apps/desktop/src/shared/themes/built-in/light.ts b/apps/desktop/src/shared/themes/built-in/light.ts index 00c4b879207..0c1144de911 100644 --- a/apps/desktop/src/shared/themes/built-in/light.ts +++ b/apps/desktop/src/shared/themes/built-in/light.ts @@ -13,9 +13,9 @@ export const lightTheme: Theme = { ui: { background: "oklch(1 0 0)", foreground: "oklch(0.145 0 0)", - card: "oklch(1 0 0)", + card: "oklch(0.97 0 0)", cardForeground: "oklch(0.145 0 0)", - popover: "oklch(1 0 0)", + popover: "oklch(0.97 0 0)", popoverForeground: "oklch(0.145 0 0)", primary: "oklch(0.205 0 0)", primaryForeground: "oklch(0.985 0 0)", @@ -23,7 +23,7 @@ export const lightTheme: Theme = { secondaryForeground: "oklch(0.205 0 0)", muted: "oklch(0.97 0 0)", mutedForeground: "oklch(0.556 0 0)", - accent: "oklch(0.97 0 0)", + accent: "oklch(0.93 0 0)", accentForeground: "oklch(0.205 0 0)", tertiary: "oklch(0.95 0.003 40)", tertiaryActive: "oklch(0.90 0.003 40)", diff --git a/apps/desktop/src/shared/themes/built-in/monokai.ts b/apps/desktop/src/shared/themes/built-in/monokai.ts index 40eb0a8bbc7..a3f2a284933 100644 --- a/apps/desktop/src/shared/themes/built-in/monokai.ts +++ b/apps/desktop/src/shared/themes/built-in/monokai.ts @@ -14,7 +14,7 @@ export const monokaiTheme: Theme = { ui: { background: "#272822", foreground: "#f8f8f2", - card: "#272822", + card: "#3e3d32", cardForeground: "#f8f8f2", popover: "#3e3d32", popoverForeground: "#f8f8f2", diff --git a/apps/desktop/src/shared/themes/built-in/one-dark.ts b/apps/desktop/src/shared/themes/built-in/one-dark.ts index 815685ec43c..7173e890e3a 100644 --- a/apps/desktop/src/shared/themes/built-in/one-dark.ts +++ b/apps/desktop/src/shared/themes/built-in/one-dark.ts @@ -14,9 +14,9 @@ export const oneDarkTheme: Theme = { ui: { background: "#282c34", foreground: "#abb2bf", - card: "#282c34", + card: "#2c313c", cardForeground: "#abb2bf", - popover: "#21252b", + popover: "#2c313c", popoverForeground: "#abb2bf", primary: "#61afef", primaryForeground: "#282c34", diff --git a/bun.lock b/bun.lock index 302490628d2..f2ad1b5b4f3 100644 --- a/bun.lock +++ b/bun.lock @@ -104,6 +104,7 @@ "line-column-path": "^3.0.0", "lodash": "^4.17.21", "lowdb": "^7.0.1", + "lucide-react": "^0.555.0", "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", @@ -1939,6 +1940,8 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index f032c0b83ae..2000312c2be 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -17,10 +17,13 @@ function TooltipProvider({ } function Tooltip({ + delayDuration, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + delayDuration?: number; +}) { return ( - + ); @@ -36,8 +39,13 @@ function TooltipContent({ className, sideOffset = 0, children, + showArrow = true, + arrowClassName, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showArrow?: boolean; + arrowClassName?: string; +}) { return ( {children} - + {showArrow && ( + + )} );