diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
| commit | 09f964451d7d92e9891430ec4595c1276d486aab (patch) | |
| tree | 28da4483f5c28924a8c47fceb648b1baebe88224 /src | |
| download | yace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz yace-09f964451d7d92e9891430ec4595c1276d486aab.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src')
33 files changed, 3108 insertions, 0 deletions
diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..d04b328 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,46 @@ +import TitleBar from '@/components/TitleBar/TitleBar' +import ActivityBar from '@/components/ActivityBar/ActivityBar' +import SidePanel from '@/components/SidePanel/SidePanel' +import TabsBar from '@/components/TabsBar/TabsBar' +import Breadcrumbs from '@/components/Breadcrumbs/Breadcrumbs' +import WelcomeScreen from '@/components/Welcome/WelcomeScreen' +import YaceEditor from '@/components/Editor/YaceEditor' +import StatusBar from '@/components/StatusBar/StatusBar' +import CommandPalette from '@/components/CommandPalette/CommandPalette' +import ContextMenu from '@/components/ContextMenu/ContextMenu' +import Toasts from '@/components/Toasts/Toasts' +import { useKeyboard } from '@/hooks/useKeyboard' +import { useLayoutStore } from '@/stores/useLayoutStore' +import { useTabsStore } from '@/stores/useTabsStore' + +export default function App() { + useKeyboard() + const sidePanelVisible = useLayoutStore((s) => s.sidePanelVisible) + const activeView = useLayoutStore((s) => s.activeView) + const activeTabId = useTabsStore((s) => s.activeTabId) + + return ( + <div className="h-screen w-screen flex flex-col overflow-hidden bg-editor-bg" onContextMenu={(e) => e.preventDefault()}> + <TitleBar /> + <CommandPalette /> + <ContextMenu /> + <Toasts /> + <div className="flex flex-1 overflow-hidden"> + <ActivityBar /> + {sidePanelVisible && activeView && <SidePanel />} + <div className="flex flex-col flex-1 overflow-hidden min-w-0"> + {activeTabId ? ( + <> + <TabsBar /> + <Breadcrumbs /> + <YaceEditor /> + </> + ) : ( + <WelcomeScreen /> + )} + </div> + </div> + <StatusBar /> + </div> + ) +} diff --git a/src/components/ActivityBar/ActivityBar.tsx b/src/components/ActivityBar/ActivityBar.tsx new file mode 100644 index 0000000..7f96b5c --- /dev/null +++ b/src/components/ActivityBar/ActivityBar.tsx @@ -0,0 +1,147 @@ +import { useLayoutStore, type ActivityView } from '@/stores/useLayoutStore' +import { useThemeStore } from '@/stores/useThemeStore' + +interface ActivityItem { + view: ActivityView + label: string +} + +function ExplorerIcon() { + return ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + </svg> + ) +} + +function SearchIcon() { + return ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="11" cy="11" r="8" /> + <line x1="21" y1="21" x2="16.65" y2="16.65" /> + </svg> + ) +} + +function ExtensionsIcon() { + return ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M4 11a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2z" /> + <path d="M15 11a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2z" /> + <path d="M15 22a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2z" /> + <path d="M4 22a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2z" /> + </svg> + ) +} + +function SettingsIcon() { + return ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="3" /> + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> + </svg> + ) +} + +function MoonIcon() { + return ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> + </svg> + ) +} + +function SunIcon() { + return ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="5" /> + <line x1="12" y1="1" x2="12" y2="3" /> + <line x1="12" y1="21" x2="12" y2="23" /> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> + <line x1="1" y1="12" x2="3" y2="12" /> + <line x1="21" y1="12" x2="23" y2="12" /> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> + </svg> + ) +} + +const activities: ActivityItem[] = [ + { view: 'explorer', label: 'Explorer' }, + { view: 'search', label: 'Search' }, + { view: 'extensions', label: 'Extensions' }, +] + +const bottomActivities: ActivityItem[] = [ + { view: 'settings', label: 'Settings' }, +] + +const iconMap: Record<string, typeof ExplorerIcon> = { + explorer: ExplorerIcon, + search: SearchIcon, + extensions: ExtensionsIcon, + settings: SettingsIcon, +} + +function ActivityButton({ item, isActive, setActiveView }: { item: ActivityItem; isActive: boolean; setActiveView: (view: ActivityView | null) => void }) { + const IconComponent = iconMap[item.view] || ExplorerIcon + + return ( + <button + role="tab" + aria-label={item.label} + aria-current={isActive ? 'true' : undefined} + className="relative flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-fast hover:bg-panel-hover" + style={{ color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-muted)' }} + onClick={() => setActiveView(item.view)} + title={item.label} + > + {isActive && ( + <span className="absolute left-0 w-[3px] h-6 rounded-r-sm bg-accent" style={{ left: '-4px' }} /> + )} + <span className={isActive ? '' : 'opacity-50 group-hover:opacity-80 transition-opacity duration-fast'}> + <IconComponent /> + </span> + </button> + ) +} + +export default function ActivityBar() { + const activeView = useLayoutStore((s) => s.activeView) + const setActiveView = useLayoutStore((s) => s.setActiveView) + const theme = useThemeStore((s) => s.theme) + const toggleTheme = useThemeStore((s) => s.toggleTheme) + + return ( + <div className="flex flex-col items-center pt-2 gap-1 w-12 shrink-0 bg-activity-bg select-none group" role="tablist" aria-label="Main navigation"> + {activities.map((item) => ( + <ActivityButton + key={item.view} + item={item} + isActive={activeView === item.view} + setActiveView={setActiveView} + /> + ))} + + <div className="flex-1" /> + + {bottomActivities.map((item) => ( + <ActivityButton + key={item.view} + item={item} + isActive={activeView === item.view} + setActiveView={setActiveView} + /> + ))} + + <button + className="flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-fast hover:bg-panel-hover text-text-muted hover:text-text-primary mb-2" + onClick={toggleTheme} + title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`} + > + {theme === 'dark' ? <SunIcon /> : <MoonIcon />} + </button> + </div> + ) +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..dbdeef0 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,55 @@ +import { useTabsStore } from '@/stores/useTabsStore' +import { useFilesStore } from '@/stores/useFilesStore' + +export default function Breadcrumbs() { + const tabs = useTabsStore((s) => s.tabs) + const activeTabId = useTabsStore((s) => s.activeTabId) + const rootPath = useFilesStore((s) => s.rootPath) + + if (!activeTabId || !rootPath) return null + + const activeTab = tabs.find((t) => t.id === activeTabId) + if (!activeTab) return null + + const fullPath = activeTab.path + let relativePath = fullPath + + if (fullPath.startsWith(rootPath)) { + relativePath = fullPath.slice(rootPath.length).replace(/^[\\/]/, '') + } + + const segments = relativePath.split(/[\\/]/) + const lastIdx = segments.length - 1 + + return ( + <div className="flex items-center h-[26px] px-4 shrink-0 bg-editor-bg select-none gap-0 text-ui-xs overflow-hidden"> + {segments.map((segment, i) => { + const isLast = i === lastIdx + + if (isLast) { + return ( + <span + key={i} + className="truncate text-text-primary font-medium" + > + {segment} + </span> + ) + } + + return ( + <span key={i} className="flex items-center gap-0 min-w-0 shrink-0"> + <button + className="truncate text-text-muted hover:text-text-secondary px-1 py-0.5 rounded transition-all duration-fast hover:bg-panel-hover cursor-pointer" + > + {segment} + </button> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-text-muted opacity-30 mx-[2px]"> + <polyline points="9 18 15 12 9 6" /> + </svg> + </span> + ) + })} + </div> + ) +} diff --git a/src/components/CommandPalette/CommandPalette.tsx b/src/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..eba6221 --- /dev/null +++ b/src/components/CommandPalette/CommandPalette.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useMemo } from 'react' +import { useCommandStore } from '@/stores/useCommandStore' + +function SearchIcon() { + return ( + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="11" cy="11" r="8" /> + <line x1="21" y1="21" x2="16.65" y2="16.65" /> + </svg> + ) +} + +function Kbd({ keys }: { keys: string }) { + return ( + <span className="flex items-center gap-0.5 text-ui-xs"> + {keys.split('+').map((k) => ( + <kbd + key={k} + className="px-1 py-[1px] rounded text-[10px] leading-none font-mono" + style={{ + backgroundColor: 'var(--color-panel-hover)', + color: 'var(--color-text-muted)', + border: '1px solid var(--color-panel-border)', + minWidth: '16px', + textAlign: 'center', + }} + > + {k === 'Ctrl' ? '⌃' : k === 'Shift' ? '⇧' : k === 'Cmd' ? '⌘' : k} + </kbd> + ))} + </span> + ) +} + +function CommandGroup({ category, children }: { category: string; children: React.ReactNode }) { + return ( + <div> + <div className="px-4 py-1.5 text-ui-xs font-medium tracking-wider uppercase text-text-muted">{category}</div> + {children} + </div> + ) +} + +export default function CommandPalette() { + const isOpen = useCommandStore((s) => s.isOpen) + const query = useCommandStore((s) => s.query) + const selectedIndex = useCommandStore((s) => s.selectedIndex) + const commands = useCommandStore((s) => s.commands) + const setQuery = useCommandStore((s) => s.setQuery) + const setSelectedIndex = useCommandStore((s) => s.setSelectedIndex) + const executeSelected = useCommandStore((s) => s.executeSelected) + const close = useCommandStore((s) => s.close) + + const inputRef = useRef<HTMLInputElement>(null) + + const filtered = useMemo( + () => commands.filter( + (c) => + c.label.toLowerCase().includes(query.toLowerCase()) || + c.category.toLowerCase().includes(query.toLowerCase()) + ), + [commands, query] + ) + + const grouped = useMemo(() => { + const map = new Map<string, typeof filtered>() + filtered.forEach((c) => { + const group = map.get(c.category) ?? [] + group.push(c) + map.set(c.category, group) + }) + return Array.from(map.entries()) + }, [filtered]) + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus() + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault() + close() + return + } + + if (e.key === 'Enter') { + e.preventDefault() + executeSelected() + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(Math.min(selectedIndex + 1, filtered.length - 1)) + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(Math.max(selectedIndex - 1, 0)) + return + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, filtered.length, selectedIndex, setSelectedIndex, executeSelected, close]) + + if (!isOpen) return null + + return ( + <div + className="fixed inset-0 z-50 flex justify-center" + style={{ paddingTop: '12vh' }} + onClick={close} + role="dialog" + aria-label="Command palette" + > + <div className="absolute inset-0 backdrop-glass" /> + + <div + className="relative w-full max-w-[520px] rounded-xl shadow-glass overflow-hidden" + style={{ + backgroundColor: 'var(--color-panel-bg)', + border: '1px solid var(--color-panel-border)', + maxHeight: '60vh', + display: 'flex', + flexDirection: 'column', + }} + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center gap-3 px-4 h-11 shrink-0" style={{ borderBottom: '1px solid var(--color-panel-border)' }}> + <span className="text-text-muted shrink-0"><SearchIcon /></span> + <input + ref={inputRef} + type="text" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Type a command..." + className="flex-1 bg-transparent text-ui-base text-text-primary outline-none placeholder:text-text-muted" + /> + </div> + + <div className="flex-1 overflow-y-auto py-2" role="listbox"> + {filtered.length === 0 && ( + <div className="px-4 py-6 text-ui-sm text-text-muted text-center"> + No results found + </div> + )} + + {grouped.map(([category, items]) => ( + <CommandGroup key={category} category={category}> + {items.map((cmd) => { + const idx = filtered.indexOf(cmd) + const isSelected = idx === selectedIndex + + return ( + <button + key={cmd.id} + role="option" + aria-selected={isSelected ? 'true' : 'false'} + className="w-full flex items-center justify-between px-4 py-1.5 text-left transition-colors duration-fast" + style={{ + backgroundColor: isSelected ? 'var(--color-panel-hover)' : 'transparent', + color: isSelected ? 'var(--color-text-primary)' : 'var(--color-text-secondary)', + }} + onMouseEnter={() => setSelectedIndex(idx)} + onClick={() => { + cmd.action() + close() + }} + > + <span className="text-ui-sm truncate">{cmd.label}</span> + {cmd.shortcut && <Kbd keys={cmd.shortcut} />} + </button> + ) + })} + </CommandGroup> + ))} + + <div className="px-4 pt-2 pb-1 flex items-center gap-3 text-ui-xs text-text-muted border-t border-panel-border mt-2"> + <span className="flex items-center gap-1"> + <Kbd keys="↑↓" /> navigate + </span> + <span className="flex items-center gap-1"> + <Kbd keys="↵" /> select + </span> + <span className="flex items-center gap-1"> + <Kbd keys="Esc" /> close + </span> + </div> + </div> + </div> + </div> + ) +} diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..2c67116 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react' +import { useContextMenuStore, type ContextMenuIcon } from '@/stores/useContextMenuStore' + +function MenuIcon({ icon }: { icon: ContextMenuIcon }) { + const c = 'currentColor' + switch (icon) { + case 'undo': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /> + </svg> + ) + case 'redo': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10" /> + </svg> + ) + case 'cut': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><line x1="8.12" y1="8.12" x2="15.88" y2="15.88" /><line x1="15.88" y1="8.12" x2="8.12" y2="15.88" /> + </svg> + ) + case 'copy': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> + </svg> + ) + case 'paste': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> + </svg> + ) + case 'selectAll': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><line x1="9" y1="3" x2="9" y2="21" /><line x1="15" y1="3" x2="15" y2="21" /><line x1="3" y1="9" x2="21" y2="9" /><line x1="3" y1="15" x2="21" y2="15" /> + </svg> + ) + case 'close': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> + </svg> + ) + case 'rename': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> + </svg> + ) + case 'trash': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> + </svg> + ) + case 'folder': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + </svg> + ) + case 'file': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14,2 14,8 20,8" /> + </svg> + ) + case 'link': + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> + </svg> + ) + } +} + +export default function ContextMenu() { + const isOpen = useContextMenuStore((s) => s.isOpen) + const x = useContextMenuStore((s) => s.x) + const y = useContextMenuStore((s) => s.y) + const items = useContextMenuStore((s) => s.items) + const close = useContextMenuStore((s) => s.close) + + const menuRef = useRef<HTMLDivElement>(null) + const [adjustedPos, setAdjustedPos] = useState({ x, y }) + const [selectedIndex, setSelectedIndex] = useState(0) + + const visibleItems = items.filter((i) => !i.separator) + const totalItems = visibleItems.length + + useEffect(() => { + if (!isOpen || !menuRef.current) return + const rect = menuRef.current.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + setAdjustedPos({ + x: x + rect.width > vw ? vw - rect.width - 12 : x, + y: y + rect.height > vh ? vh - rect.height - 12 : y, + }) + setSelectedIndex(0) + }, [isOpen, x, y]) + + useEffect(() => { + if (!isOpen) return + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { e.preventDefault(); close(); return } + if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, totalItems - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); return } + if (e.key === 'Enter') { + e.preventDefault() + const item = visibleItems[selectedIndex] + if (item && !item.disabled) { item.action(); close() } + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, selectedIndex, totalItems, visibleItems, close]) + + useEffect(() => { + if (!isOpen || !menuRef.current) return + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + close() + } + } + requestAnimationFrame(() => window.addEventListener('mousedown', handleClickOutside)) + return () => window.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, close]) + + if (!isOpen) return null + + return ( + <div + ref={menuRef} + className="fixed z-50 min-w-[180px] py-1 rounded-xl shadow-glass overflow-hidden context-menu" + style={{ + left: adjustedPos.x, + top: adjustedPos.y, + backgroundColor: 'var(--color-panel-bg)', + border: '1px solid var(--color-panel-border)', + }} + > + {items.map((item) => { + if (item.separator) { + return ( + <div key={item.id} className="my-1 mx-2" style={{ height: '1px', backgroundColor: 'var(--color-panel-border)' }} /> + ) + } + const idx = visibleItems.indexOf(item) + const isSelected = idx === selectedIndex + return ( + <button + key={item.id} + role="menuitem" + disabled={item.disabled} + className="w-full flex items-center gap-2.5 px-3 py-1.5 text-ui-sm text-left transition-colors duration-fast disabled:opacity-30 disabled:cursor-default" + style={{ + backgroundColor: isSelected ? 'var(--color-panel-hover)' : 'transparent', + color: item.disabled ? 'var(--color-text-muted)' : 'var(--color-text-secondary)', + }} + onMouseEnter={() => setSelectedIndex(idx)} + onClick={() => { if (!item.disabled) { item.action(); close() } }} + > + {item.icon && ( + <span className="w-[14px] shrink-0 text-text-muted"> + <MenuIcon icon={item.icon} /> + </span> + )} + {!item.icon && <span className="w-[14px] shrink-0" />} + <span className="flex-1 truncate">{item.label}</span> + {item.shortcut && ( + <span className="text-ui-xs text-text-muted ml-4">{item.shortcut}</span> + )} + </button> + ) + })} + </div> + ) +} diff --git a/src/components/Editor/YaceEditor.tsx b/src/components/Editor/YaceEditor.tsx new file mode 100644 index 0000000..f3152e1 --- /dev/null +++ b/src/components/Editor/YaceEditor.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useRef, useMemo } from 'react' +import { + EditorView, keymap, + highlightActiveLine, highlightActiveLineGutter, + lineNumbers, +} from '@codemirror/view' +import { EditorState, type Extension } from '@codemirror/state' +import { defaultKeymap, indentWithTab, undo, redo } from '@codemirror/commands' +import { syntaxHighlighting, indentUnit, HighlightStyle } from '@codemirror/language' +import { tags } from '@lezer/highlight' +import { json } from '@codemirror/lang-json' +import { javascript } from '@codemirror/lang-javascript' +import { markdown } from '@codemirror/lang-markdown' +import { html } from '@codemirror/lang-html' +import { css } from '@codemirror/lang-css' +import { rust } from '@codemirror/lang-rust' +import { python } from '@codemirror/lang-python' +import { useTabsStore } from '@/stores/useTabsStore' +import { useContextMenuStore } from '@/stores/useContextMenuStore' +import { useSettingsStore } from '@/stores/useSettingsStore' + +function buildLanguageExtensions(language: string) { + switch (language) { + case 'typescript': + return javascript({ typescript: true }) + case 'javascript': + return javascript() + case 'json': + return json() + case 'markdown': + return markdown() + case 'html': + return html() + case 'css': + return css() + case 'rust': + return rust() + case 'python': + return python() + default: + return [] + } +} + +const tokyoNightSyntax = HighlightStyle.define([ + { tag: tags.keyword, color: '#7aa2f7' }, + { tag: [tags.controlKeyword, tags.operatorKeyword], color: '#7aa2f7' }, + { tag: tags.modifier, color: '#7aa2f7' }, + { tag: tags.definitionKeyword, color: '#7aa2f7' }, + { tag: tags.moduleKeyword, color: '#7aa2f7' }, + { tag: tags.self, color: '#ff5370' }, + { tag: tags.atom, color: '#ff9e64' }, + { tag: tags.bool, color: '#ff9e64' }, + { tag: tags.null, color: '#ff9e64' }, + { tag: tags.number, color: '#ff9e64' }, + { tag: tags.string, color: '#9ece6a' }, + { tag: tags.character, color: '#9ece6a' }, + { tag: tags.regexp, color: '#89ddff' }, + { tag: tags.escape, color: '#bb9af7' }, + { tag: tags.typeName, color: '#e0af68' }, + { tag: tags.className, color: '#e0af68' }, + { tag: tags.namespace, color: '#89ddff' }, + { tag: tags.macroName, color: '#ff5370' }, + { tag: tags.propertyName, color: '#73daca' }, + { tag: tags.attributeName, color: '#73daca' }, + { tag: tags.variableName, color: '#c0caf5' }, + { tag: tags.labelName, color: '#c0caf5' }, + { tag: tags.operator, color: '#89ddff' }, + { tag: tags.bracket, color: '#a9b1d6' }, + { tag: tags.angleBracket, color: '#89ddff' }, + { tag: tags.paren, color: '#a9b1d6' }, + { tag: tags.brace, color: '#a9b1d6' }, + { tag: tags.squareBracket, color: '#a9b1d6' }, + { tag: tags.separator, color: '#a9b1d6' }, + { tag: tags.punctuation, color: '#a9b1d6' }, + { tag: tags.comment, color: '#565f89', fontStyle: 'italic' }, + { tag: tags.lineComment, color: '#565f89', fontStyle: 'italic' }, + { tag: tags.blockComment, color: '#565f89', fontStyle: 'italic' }, + { tag: tags.docComment, color: '#565f89', fontStyle: 'italic' }, + { tag: tags.docString, color: '#565f89' }, + { tag: tags.meta, color: '#565f89' }, + { tag: tags.content, color: '#c0caf5' }, + { tag: tags.heading, color: '#7aa2f7', fontWeight: '600' }, + { tag: tags.strong, color: '#c0caf5', fontWeight: '700' }, + { tag: tags.emphasis, color: '#c0caf5', fontStyle: 'italic' }, + { tag: tags.strikethrough, color: '#565f89', textDecoration: 'line-through' }, + { tag: tags.link, color: '#73daca', textDecoration: 'underline' }, + { tag: tags.url, color: '#73daca', textDecoration: 'underline' }, + { tag: tags.list, color: '#7dcfff' }, + { tag: tags.quote, color: '#9ece6a' }, + { tag: tags.inserted, color: '#9ece6a' }, + { tag: tags.deleted, color: '#ff5370' }, + { tag: tags.changed, color: '#e0af68' }, + { tag: tags.invalid, color: '#ff5370' }, + { tag: tags.definition(tags.variableName), color: '#c0caf5' }, + { tag: tags.definition(tags.propertyName), color: '#73daca' }, + { tag: tags.function(tags.variableName), color: '#7dcfff' }, + { tag: tags.definition(tags.function(tags.variableName)), color: '#7dcfff' }, + { tag: tags.local(tags.variableName), color: '#c0caf5' }, + { tag: tags.special(tags.variableName), color: '#ff5370' }, + { tag: tags.special(tags.string), color: '#bb9af7' }, +]) + +function buildEditorTheme(settings: { + fontSize: number + fontFamily: string + ruler: boolean + rulerColumn: number +}) { + const rulerCSS = settings.ruler + ? `linear-gradient(90deg, transparent calc(${settings.rulerColumn}ch + 0.5px), var(--color-ruler) calc(${settings.rulerColumn}ch + 0.5px), var(--color-ruler) calc(${settings.rulerColumn + 0.5}ch + 0.5px), transparent calc(${settings.rulerColumn + 0.5}ch + 0.5px))` + : 'none' + + return EditorView.theme({ + '&': { + fontSize: `${settings.fontSize}px`, + fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, + height: '100%', + backgroundColor: 'var(--color-editor-bg)', + }, + '.cm-scroller': { + fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, + }, + '.cm-content': { + padding: '16px 0', + caretColor: 'var(--color-accent)', + backgroundImage: rulerCSS, + backgroundPosition: '0 0', + backgroundRepeat: 'no-repeat', + }, + '.cm-line': { + padding: '0 20px', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-editor-surface)', + borderRight: '1px solid var(--color-panel-border)', + color: 'var(--color-text-muted)', + }, + '.cm-lineNumbers .cm-gutterElement': { + padding: '0 12px 0 16px', + fontSize: '12px', + fontFamily: `'${settings.fontFamily}', 'Cascadia Code', 'Fira Code', Consolas, monospace`, + lineHeight: '1.6', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--color-editor-line)', + color: 'var(--color-text-primary)', + fontWeight: '600', + }, + '.cm-activeLine': { + backgroundColor: 'rgba(12,12,14,0.5)', + }, + '.cm-cursor': { + borderLeftWidth: '2px', + borderLeftColor: 'var(--color-accent)', + }, + '&.cm-focused .cm-cursor': { + borderLeftColor: 'var(--color-accent)', + }, + '.cm-selectionBackground': { + backgroundColor: 'rgba(240,160,48,0.2) !important', + }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'rgba(240,160,48,0.25) !important', + }, + '.cm-selectionMatch': { + backgroundColor: 'rgba(240,160,48,0.1) !important', + }, + }) +} + +export default function YaceEditor() { + const editorRef = useRef<HTMLDivElement>(null) + const viewRef = useRef<EditorView | null>(null) + const containerRef = useRef<HTMLDivElement>(null) + const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) + + const activeTab = useTabsStore((s) => + s.activeTabId ? s.tabs.find((t) => t.id === s.activeTabId) ?? null : null + ) + const updateContent = useTabsStore((s) => s.updateContent) + const setCursorPosition = useTabsStore((s) => s.setCursorPosition) + const openContextMenu = useContextMenuStore((s) => s.open) + + const fontSize = useSettingsStore((s) => s.fontSize) + const tabSize = useSettingsStore((s) => s.tabSize) + const fontFamily = useSettingsStore((s) => s.fontFamily) + const wordWrap = useSettingsStore((s) => s.wordWrap) + const lineNumbersEnabled = useSettingsStore((s) => s.lineNumbers) + const ruler = useSettingsStore((s) => s.ruler) + const rulerColumn = useSettingsStore((s) => s.rulerColumn) + + const settings = { fontSize, tabSize, fontFamily, wordWrap, lineNumbers: lineNumbersEnabled, ruler, rulerColumn } + + const editorItems = useMemo(() => [ + { id: 'undo', label: 'Undo', shortcut: 'Ctrl+Z', icon: 'undo' as const, action: () => viewRef.current && undo(viewRef.current) }, + { id: 'redo', label: 'Redo', shortcut: 'Ctrl+Shift+Z', icon: 'redo' as const, action: () => viewRef.current && redo(viewRef.current) }, + { id: 'sep1', label: '', separator: true, action: () => {} }, + { id: 'cut', label: 'Cut', shortcut: 'Ctrl+X', icon: 'cut' as const, action: () => { document.execCommand('cut') } }, + { id: 'copy', label: 'Copy', shortcut: 'Ctrl+C', icon: 'copy' as const, action: () => { document.execCommand('copy') } }, + { id: 'paste', label: 'Paste', shortcut: 'Ctrl+V', icon: 'paste' as const, action: () => { navigator.clipboard.readText().then((t) => viewRef.current?.dispatch({ changes: { from: viewRef.current.state.selection.main.head, insert: t } })) } }, + { id: 'sep2', label: '', separator: true, action: () => {} }, + { id: 'selectAll', label: 'Select All', shortcut: 'Ctrl+A', icon: 'selectAll' as const, action: () => { viewRef.current?.dispatch({ selection: { anchor: 0, head: viewRef.current.state.doc.length } }) } }, + ], []) + + const handleContextMenu = useCallback((e: MouseEvent) => { + e.preventDefault() + openContextMenu(e.clientX, e.clientY, editorItems) + }, [openContextMenu, editorItems]) + + const handleChange = useCallback( + (content: string) => { + if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current) + updateTimeoutRef.current = setTimeout(() => { + const id = useTabsStore.getState().activeTabId + if (id) updateContent(id, content) + }, 300) + }, + [updateContent] + ) + + useEffect(() => { + if (!editorRef.current) return + + if (viewRef.current) viewRef.current.destroy() + + const languageExtensions = activeTab + ? buildLanguageExtensions(activeTab.language) + : [] + + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged) { + handleChange(update.state.doc.toString()) + } + if (update.selectionSet) { + const pos = update.state.selection.main.head + const line = update.state.doc.lineAt(pos) + setCursorPosition(line.number, pos - line.from + 1) + } + }) + + const extensions: Extension[] = [ + ...(lineNumbersEnabled ? [lineNumbers(), highlightActiveLineGutter()] : [highlightActiveLineGutter()]), + highlightActiveLine(), + updateListener, + keymap.of([...defaultKeymap, indentWithTab]), + syntaxHighlighting(tokyoNightSyntax), + buildEditorTheme(settings), + EditorState.tabSize.of(tabSize), + indentUnit.of(' '.repeat(tabSize)), + languageExtensions, + ] + + if (wordWrap) { + extensions.push(EditorView.lineWrapping) + } + + const state = EditorState.create({ + doc: activeTab?.content ?? '', + extensions: extensions.flat(), + }) + + viewRef.current = new EditorView({ + state, + parent: editorRef.current, + }) + + return () => { + if (updateTimeoutRef.current) clearTimeout(updateTimeoutRef.current) + viewRef.current?.destroy() + viewRef.current = null + } + }, [activeTab?.id, activeTab?.language, fontSize, tabSize, fontFamily, wordWrap, lineNumbersEnabled, ruler, rulerColumn]) + + useEffect(() => { + const el = containerRef.current + if (!el) return + el.addEventListener('contextmenu', handleContextMenu) + return () => el.removeEventListener('contextmenu', handleContextMenu) + }, [handleContextMenu]) + + if (!activeTab) { + return null + } + + return ( + <div ref={containerRef} className="flex-1 overflow-hidden bg-editor-bg"> + <div ref={editorRef} className="h-full" /> + </div> + ) +} diff --git a/src/components/Settings/SettingsPanel.tsx b/src/components/Settings/SettingsPanel.tsx new file mode 100644 index 0000000..e4a4b6b --- /dev/null +++ b/src/components/Settings/SettingsPanel.tsx @@ -0,0 +1,106 @@ +import { useSettingsStore } from '@/stores/useSettingsStore' +import { useThemeStore } from '@/stores/useThemeStore' +import Toggle from '@/components/UI/Toggle' +import Slider from '@/components/UI/Slider' +import Input from '@/components/UI/Input' + +function Section({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <div className="mb-6"> + <div className="px-3 py-1.5 text-ui-xs font-medium tracking-[0.08em] uppercase text-text-muted"> + {label} + </div> + <div className="flex flex-col">{children}</div> + </div> + ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <div className="flex items-center justify-between px-3 py-2 min-h-[34px] gap-4"> + <span className="text-ui-sm text-text-secondary shrink-0">{label}</span> + <div className="w-[140px] shrink-0">{children}</div> + </div> + ) +} + +export default function SettingsPanel() { + const settings = useSettingsStore() + const toggleTheme = useThemeStore((s) => s.toggleTheme) + const theme = useThemeStore((s) => s.theme) + + return ( + <div className="flex-1 overflow-y-auto overflow-x-hidden"> + <Section label="Interface"> + <Row label="Theme"> + <button + onClick={toggleTheme} + className="flex items-center justify-between w-full h-7 px-2.5 rounded-md text-ui-sm text-text-primary bg-editor-surface border transition-all duration-150 hover:brightness-110" + style={{ borderColor: 'var(--color-panel-border)' }} + > + <span>{theme === 'dark' ? 'Dark' : 'Light'}</span> + <span className="text-text-muted text-ui-xs"> + {theme === 'dark' ? '🌙' : '☀️'} + </span> + </button> + </Row> + <Row label="Font Size"> + <div className="flex items-center gap-2"> + <Slider + value={settings.fontSize} + min={10} + max={24} + step={1} + onChange={(v) => settings.set('fontSize', v)} + /> + <span className="text-ui-xs text-text-muted w-5 text-right tabular-nums">{settings.fontSize}</span> + </div> + </Row> + <Row label="Tab Size"> + <div className="flex items-center gap-2"> + <Slider + value={settings.tabSize} + min={1} + max={8} + step={1} + onChange={(v) => settings.set('tabSize', v)} + /> + <span className="text-ui-xs text-text-muted w-5 text-right tabular-nums">{settings.tabSize}</span> + </div> + </Row> + </Section> + + <Section label="Editor"> + <Row label="Word Wrap"> + <Toggle + checked={settings.wordWrap} + onChange={(v) => settings.set('wordWrap', v)} + /> + </Row> + <Row label="Line Numbers"> + <Toggle + checked={settings.lineNumbers} + onChange={(v) => settings.set('lineNumbers', v)} + /> + </Row> + <Row label="Ruler"> + <Toggle + checked={settings.ruler} + onChange={(v) => settings.set('ruler', v)} + /> + </Row> + <Row label="Ruler Column"> + <Input + type="number" + value={settings.rulerColumn} + min={40} + max={200} + onChange={(v) => settings.set('rulerColumn', parseInt(v) || 80)} + /> + </Row> + </Section> + + <div className="h-2" /> + </div> + ) +} diff --git a/src/components/SidePanel/SidePanel.tsx b/src/components/SidePanel/SidePanel.tsx new file mode 100644 index 0000000..14668fb --- /dev/null +++ b/src/components/SidePanel/SidePanel.tsx @@ -0,0 +1,187 @@ +import { useRef, useCallback, useEffect } from 'react' +import FileTree from '@/components/Sidebar/FileTree' +import SettingsPanel from '@/components/Settings/SettingsPanel' +import { useFilesStore } from '@/stores/useFilesStore' +import { useTabsStore } from '@/stores/useTabsStore' +import { useLayoutStore } from '@/stores/useLayoutStore' + +function NewFileIcon() { + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <polyline points="14 2 14 8 20 8" /> + <line x1="12" y1="18" x2="12" y2="12" /> + <line x1="9" y1="15" x2="15" y2="15" /> + </svg> + ) +} + +function NewFolderIcon() { + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + <line x1="12" y1="11" x2="12" y2="17" /> + <line x1="9" y1="14" x2="15" y2="14" /> + </svg> + ) +} + +function OpenFolderIcon() { + return ( + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + </svg> + ) +} + +export default function SidePanel() { + const fileTree = useFilesStore((s) => s.fileTree) + const rootPath = useFilesStore((s) => s.rootPath) + const loading = useFilesStore((s) => s.loading) + const openFolder = useFilesStore((s) => s.openFolder) + const createFolder = useFilesStore((s) => s.createFolder) + const createUntitledFile = useTabsStore((s) => s.createUntitledFile) + + const sidePanelWidth = useLayoutStore((s) => s.sidePanelWidth) + const setSidePanelWidth = useLayoutStore((s) => s.setSidePanelWidth) + const activeView = useLayoutStore((s) => s.activeView) + + const dragRef = useRef(false) + const panelRef = useRef<HTMLDivElement>(null) + + const handleMouseDown = useCallback(() => { + dragRef.current = true + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, []) + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragRef.current || !panelRef.current) return + const rect = panelRef.current.getBoundingClientRect() + const newWidth = e.clientX - rect.left + setSidePanelWidth(newWidth) + }, [setSidePanelWidth]) + + const handleMouseUp = useCallback(() => { + dragRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + useEffect(() => { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [handleMouseMove, handleMouseUp]) + + return ( + <div + ref={panelRef} + className="relative flex flex-col h-full panel-mica select-none shrink-0 overflow-hidden" + style={{ width: `${sidePanelWidth}px` }} + role="complementary" + aria-label="Side panel" + > + <div className="group flex items-center justify-between px-3 shrink-0 h-tabs border-b border-panel-border"> + <span className="uppercase tracking-widest text-[11px] font-medium text-zinc-500"> + {activeView === 'search' ? 'SEARCH' : activeView === 'extensions' ? 'EXTENSIONS' : activeView === 'settings' ? 'SETTINGS' : 'EXPLORER'} + </span> + <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast"> + {activeView === 'explorer' && ( + <> + <button + onClick={() => createUntitledFile()} + className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-panel-hover transition-all duration-fast" + title="New File" + > + <NewFileIcon /> + </button> + {rootPath && ( + <button + onClick={() => createFolder()} + className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-panel-hover transition-all duration-fast" + title="New Folder" + > + <NewFolderIcon /> + </button> + )} + <button + onClick={openFolder} + className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-panel-hover transition-all duration-fast" + title={rootPath ? 'Open another folder' : 'Open Folder'} + > + <OpenFolderIcon /> + </button> + </> + )} + </div> + </div> + + <div className="flex-1 overflow-y-auto overflow-x-hidden"> + {activeView === 'explorer' && ( + <> + {!rootPath && !loading && ( + <div className="flex flex-col items-center gap-3 px-4 py-8 text-center"> + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted"> + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + </svg> + <p className="text-ui-sm text-text-secondary">No folder open</p> + <button + onClick={openFolder} + className="px-3 py-1.5 text-ui-xs font-medium rounded transition-all duration-150 hover:brightness-110 bg-accent text-editor-bg" + > + Open Folder + </button> + </div> + )} + + {loading && ( + <div className="flex items-center justify-center gap-2 py-8"> + <div className="w-3 h-3 rounded-full animate-pulse bg-accent" /> + <span className="text-ui-sm text-text-secondary">Loading...</span> + </div> + )} + + {rootPath && !loading && ( + <> + <div className="px-3 py-1.5 text-ui-xs font-medium truncate text-text-muted border-b border-panel-border" title={rootPath}> + {rootPath.split(/[\\/]/).pop()} + </div> + {fileTree.length > 0 ? ( + <FileTree entries={fileTree} /> + ) : ( + <div className="py-8 text-ui-sm text-text-secondary text-center">Empty directory</div> + )} + </> + )} + </> + )} + + {activeView === 'search' && ( + <div className="flex items-center justify-center py-8 text-ui-sm text-text-muted"> + Search coming soon + </div> + )} + + {activeView === 'extensions' && ( + <div className="flex items-center justify-center py-8 text-ui-sm text-text-muted"> + Extensions coming soon + </div> + )} + + {activeView === 'settings' && ( + <SettingsPanel /> + )} + </div> + + <div + className="absolute right-0 top-0 bottom-0 w-[4px] cursor-col-resize hover:bg-accent/30 transition-colors duration-fast" + style={{ right: '-2px' }} + onMouseDown={handleMouseDown} + /> + </div> + ) +} diff --git a/src/components/Sidebar/FileTree.tsx b/src/components/Sidebar/FileTree.tsx new file mode 100644 index 0000000..03e05e7 --- /dev/null +++ b/src/components/Sidebar/FileTree.tsx @@ -0,0 +1,227 @@ +import { useState, useCallback } from 'react' +import type { FileEntry } from '@/types' +import { useTabsStore } from '@/stores/useTabsStore' +import { useContextMenuStore } from '@/stores/useContextMenuStore' + +const extColors: Record<string, string> = { + ts: '#3178c6', + tsx: '#3178c6', + js: '#f7df1e', + jsx: '#f7df1e', + rs: '#dea584', + json: '#5eb7e0', + md: '#e8e8ed', + html: '#e34c4c', + css: '#42a5f5', + py: '#3572a5', +} + +const ROW_HEIGHT = 32 +const INDENT = 16 +const GUIDE_COLOR = 'var(--color-panel-border)' +const GUIDE_HOVER_COLOR = 'var(--color-text-muted)' + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + <svg + width="16" height="16" viewBox="0 0 24 24" + fill="none" stroke="currentColor" + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + className="transition-transform duration-150 shrink-0 text-text-muted" + style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }} + > + <polyline points="9 18 15 12 9 6" /> + </svg> + ) +} + +function FileTypeIcon({ name, isDir, expanded }: { name: string; isDir: boolean; expanded: boolean }) { + if (isDir) { + return ( + <svg + width="16" height="16" viewBox="0 0 24 24" + fill={expanded ? 'currentColor' : '#5c5e64'} + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + className={`shrink-0 transition-colors duration-150 ${expanded ? 'text-accent' : 'text-text-muted'}`} + > + {expanded ? ( + <path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1M5 19l3-10h15l-3 10z" /> + ) : ( + <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /> + )} + </svg> + ) + } + + const ext = name.split('.').pop()?.toLowerCase() ?? '' + const color = extColors[ext] ?? '#5c5e64' + + return ( + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <polyline points="14,2 14,8 20,8" /> + </svg> + ) +} + +interface GuideLinesProps { + depth: number + branchDepths: number[] + isLast: boolean + parentHovered: boolean +} + +function GuideLines({ depth, branchDepths, isLast, parentHovered }: GuideLinesProps) { + const color = parentHovered ? GUIDE_HOVER_COLOR : GUIDE_COLOR + const mid = ROW_HEIGHT / 2 + const leftBase = 12 + + return ( + <div className="absolute inset-0 pointer-events-none"> + {branchDepths.map((bd) => { + const l = leftBase + bd * INDENT + return ( + <div + key={bd} + className="absolute top-0 bottom-0" + style={{ left: `${l}px`, width: '1px', backgroundColor: color }} + /> + ) + })} + <div + className="absolute" + style={{ + left: `${leftBase + depth * INDENT}px`, + width: '1px', + top: 0, + bottom: isLast ? `calc(100% - ${mid}px)` : 0, + backgroundColor: color, + }} + /> + <div + className="absolute" + style={{ + left: `${leftBase + depth * INDENT}px`, + width: '7px', + height: '1px', + top: `${mid}px`, + backgroundColor: color, + }} + /> + </div> + ) +} + +interface FileTreeItemProps { + entry: FileEntry + depth: number + isLast: boolean + branchDepths: number[] +} + +function FileTreeItem({ entry, depth, isLast, branchDepths }: FileTreeItemProps) { + const [expanded, setExpanded] = useState(depth < 1) + const [parentHovered, setParentHovered] = useState(false) + const openFile = useTabsStore((s) => s.openFile) + const activeTabId = useTabsStore((s) => s.activeTabId) + const openContextMenu = useContextMenuStore((s) => s.open) + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + openContextMenu(e.clientX, e.clientY, [ + { id: 'open', label: entry.is_dir ? 'Expand' : 'Open', icon: entry.is_dir ? 'folder' : 'file', action: () => entry.is_dir ? setExpanded(!expanded) : openFile(entry.path) }, + { id: 'sep1', label: '', separator: true, action: () => {} }, + { id: 'copyPath', label: 'Copy Path', icon: 'link', action: () => navigator.clipboard.writeText(entry.path) }, + ]) + }, [entry, expanded, openFile, openContextMenu]) + + if (entry.is_dir) { + return ( + <div> + <button + className="relative w-full flex items-center text-ui-sm transition-colors duration-fast hover:bg-panel-hover focus-visible:outline-none" + style={{ height: `${ROW_HEIGHT}px` }} + tabIndex={0} + aria-expanded={expanded ? 'true' : 'false'} + aria-label={`${entry.name} (folder)`} + onMouseEnter={() => setParentHovered(true)} + onMouseLeave={() => setParentHovered(false)} + onClick={() => setExpanded(!expanded)} + onContextMenu={handleContextMenu} + > + <GuideLines depth={depth} branchDepths={branchDepths} isLast={isLast} parentHovered={parentHovered} /> + <div className="flex items-center gap-1 relative pl-2" style={{ paddingLeft: `${12 + depth * INDENT + 18}px` }}> + <ChevronIcon expanded={expanded} /> + <FileTypeIcon name={entry.name} isDir expanded={expanded} /> + <span className="truncate text-text-primary">{entry.name}</span> + </div> + </button> + <div + className="overflow-hidden transition-all duration-normal" + style={{ maxHeight: expanded ? '9999px' : '0px', opacity: expanded ? 1 : 0 }} + > + {entry.children && ( + <FileTree + entries={entry.children} + depth={depth + 1} + parentBranchDepths={isLast ? branchDepths : [...branchDepths, depth]} + /> + )} + </div> + </div> + ) + } + + const isActive = entry.path === activeTabId + + return ( + <button + className={`relative w-full flex items-center text-ui-sm transition-colors duration-fast hover:bg-panel-hover focus-visible:outline-none ${isActive ? 'bg-panel-active' : ''}`} + style={{ height: `${ROW_HEIGHT}px` }} + tabIndex={0} + aria-selected={isActive ? 'true' : 'false'} + aria-label={entry.name} + onClick={() => openFile(entry.path)} + onContextMenu={handleContextMenu} + > + <GuideLines depth={depth} branchDepths={branchDepths} isLast={isLast} parentHovered={false} /> + <div + className="flex items-center gap-1.5 relative pl-2" + style={{ paddingLeft: `${12 + depth * INDENT + 24}px` }} + > + <FileTypeIcon name={entry.name} isDir={false} expanded={false} /> + <span + className="truncate" + style={{ color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)' }} + > + {entry.name} + </span> + </div> + </button> + ) +} + +interface FileTreeProps { + entries: FileEntry[] + depth?: number + parentBranchDepths?: number[] +} + +export default function FileTree({ entries, depth = 0, parentBranchDepths = [] }: FileTreeProps) { + return ( + <div> + {entries.map((entry, idx) => ( + <FileTreeItem + key={entry.path} + entry={entry} + depth={depth} + isLast={idx === entries.length - 1} + branchDepths={parentBranchDepths} + /> + ))} + </div> + ) +} diff --git a/src/components/StatusBar/StatusBar.tsx b/src/components/StatusBar/StatusBar.tsx new file mode 100644 index 0000000..49f9346 --- /dev/null +++ b/src/components/StatusBar/StatusBar.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from 'react' +import { useTabsStore } from '@/stores/useTabsStore' +import { useSettingsStore } from '@/stores/useSettingsStore' + +function BranchIcon() { + return ( + <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 opacity-60"> + <line x1="6" y1="3" x2="6" y2="15" /> + <circle cx="18" cy="6" r="3" /> + <circle cx="6" cy="18" r="3" /> + <path d="M18 9a9 9 0 0 1-9 9" /> + </svg> + ) +} + +function ErrorIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> + <circle cx="12" cy="12" r="10" /> + <line x1="12" y1="8" x2="12" y2="12" /> + <line x1="12" y1="16" x2="12.01" y2="16" /> + </svg> + ) +} + +function EncodingIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 opacity-60"> + <polyline points="16 18 22 12 16 6" /> + <polyline points="8 6 2 12 8 18" /> + </svg> + ) +} + +function Spinner() { + return ( + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 animate-spin"> + <path d="M21 12a9 9 0 1 1-6.219-8.56" /> + </svg> + ) +} + +function Separator() { + return ( + <div className="w-px h-3 mx-2 shrink-0" style={{ backgroundColor: 'var(--color-panel-border)' }} /> + ) +} + +function StatusItem({ + children, + title, + onClick, +}: { + children: React.ReactNode + title?: string + onClick?: () => void +}) { + return ( + <button + className={`flex items-center gap-1 px-1.5 h-full text-[11px] font-medium transition-colors duration-fast ${onClick ? 'cursor-pointer' : 'cursor-default'} hover:text-text-primary`} + style={{ color: 'var(--color-text-muted)' }} + title={title} + onClick={onClick} + > + {children} + </button> + ) +} + +export default function StatusBar() { + const activeTab = useTabsStore((s) => + s.activeTabId ? s.tabs.find((t) => t.id === s.activeTabId) ?? null : null + ) + const cursorLine = useTabsStore((s) => s.cursorLine) + const cursorCol = useTabsStore((s) => s.cursorCol) + const savingId = useTabsStore((s) => s.savingId) + const statusMessage = useTabsStore((s) => s.statusMessage) + const clearStatusMessage = useTabsStore((s) => s.clearStatusMessage) + const tabSize = useSettingsStore((s) => s.tabSize) + + const isSaving = savingId === activeTab?.id + const statusTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) + + useEffect(() => { + if (statusMessage) { + if (statusTimer.current) clearTimeout(statusTimer.current) + statusTimer.current = setTimeout(clearStatusMessage, 2000) + } + return () => { if (statusTimer.current) clearTimeout(statusTimer.current) } + }, [statusMessage, clearStatusMessage]) + + return ( + <div className="flex items-center h-status shrink-0 select-none bg-activity-bg text-[11px] text-text-muted font-medium" role="status" aria-label="Application status"> + <div className="flex items-center h-full"> + <StatusItem title="Git branch: main"> + <BranchIcon /> + <span>main</span> + </StatusItem> + + <Separator /> + + <StatusItem title="0 errors, 0 warnings"> + <ErrorIcon /> + <span className="font-mono tabular-nums text-semantic-error">0</span> + <span className="font-mono tabular-nums text-semantic-warning">0</span> + </StatusItem> + </div> + + <div className="flex-1" /> + + <div className="flex items-center h-full"> + {isSaving && ( + <StatusItem title="Saving..."> + <Spinner /> + <span className="text-accent">Saving</span> + </StatusItem> + )} + + {statusMessage && !isSaving && ( + <StatusItem title={statusMessage}> + <span className={statusMessage === 'Saved' ? 'text-semantic-success' : 'text-semantic-error'}> + {statusMessage === 'Saved' ? '✓' : '✗'} + </span> + <span className={statusMessage === 'Saved' ? 'text-semantic-success' : 'text-semantic-error'}> + {statusMessage} + </span> + </StatusItem> + )} + + {activeTab && ( + <StatusItem title={`Language: ${activeTab.language}`}> + <span className="text-text-secondary"> + {activeTab.language.charAt(0).toUpperCase() + activeTab.language.slice(1)} + </span> + </StatusItem> + )} + + <Separator /> + + <StatusItem title={`Tab Size: ${tabSize}`}> + <span>Spaces: {tabSize}</span> + </StatusItem> + + <Separator /> + + <StatusItem title="Encoding: UTF-8"> + <EncodingIcon /> + <span>UTF-8</span> + </StatusItem> + + <Separator /> + + {activeTab && ( + <StatusItem title={`Line ${cursorLine}, Column ${cursorCol}`}> + <span className="font-mono tabular-nums text-text-secondary"> + Ln {cursorLine}, Col {cursorCol} + </span> + </StatusItem> + )} + </div> + </div> + ) +} diff --git a/src/components/TabsBar/TabsBar.tsx b/src/components/TabsBar/TabsBar.tsx new file mode 100644 index 0000000..86c77ea --- /dev/null +++ b/src/components/TabsBar/TabsBar.tsx @@ -0,0 +1,136 @@ +import { useRef, useEffect, useCallback, useState } from 'react' +import { useTabsStore } from '@/stores/useTabsStore' +import { useContextMenuStore } from '@/stores/useContextMenuStore' + +function CloseIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> + <line x1="18" y1="6" x2="6" y2="18" /> + <line x1="6" y1="6" x2="18" y2="18" /> + </svg> + ) +} + +export default function TabsBar() { + const tabs = useTabsStore((s) => s.tabs) + const activeTabId = useTabsStore((s) => s.activeTabId) + const setActiveTab = useTabsStore((s) => s.setActiveTab) + const closeTab = useTabsStore((s) => s.closeTab) + const openContextMenu = useContextMenuStore((s) => s.open) + + const scrollRef = useRef<HTMLDivElement>(null) + const activeTabRef = useRef<HTMLDivElement>(null) + const [showLeftFade, setShowLeftFade] = useState(false) + const [showRightFade, setShowRightFade] = useState(true) + + const handleTabContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { + e.preventDefault() + const numTabs = tabs.length + openContextMenu(e.clientX, e.clientY, [ + { id: 'close', label: 'Close', shortcut: 'Ctrl+W', icon: 'close', action: () => closeTab(tabId) }, + { id: 'closeOthers', label: 'Close Others', action: () => { tabs.forEach((t) => { if (t.id !== tabId) closeTab(t.id) }) }, disabled: numTabs < 2 }, + { id: 'closeAll', label: 'Close All', action: () => { tabs.forEach((t) => closeTab(t.id)) }, disabled: numTabs < 1 }, + { id: 'sep1', label: '', separator: true, action: () => {} }, + { id: 'copyPath', label: 'Copy Path', icon: 'link', action: () => { const tab = tabs.find((t) => t.id === tabId); if (tab) navigator.clipboard.writeText(tab.path) } }, + ]) + }, [tabs, closeTab, openContextMenu]) + + const updateFades = useCallback(() => { + const el = scrollRef.current + if (!el) return + setShowLeftFade(el.scrollLeft > 4) + setShowRightFade(el.scrollLeft < el.scrollWidth - el.clientWidth - 4) + }, []) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + el.addEventListener('scroll', updateFades, { passive: true }) + updateFades() + return () => el.removeEventListener('scroll', updateFades) + }, [updateFades, tabs.length]) + + useEffect(() => { + const el = scrollRef.current + const tabEl = activeTabRef.current + if (!el || !tabEl) return + + const containerRect = el.getBoundingClientRect() + const tabRect = tabEl.getBoundingClientRect() + const isVisible = + tabRect.left >= containerRect.left && tabRect.right <= containerRect.right + + if (!isVisible) { + const offset = tabRect.left - containerRect.left + const centerOffset = offset - containerRect.width / 2 + tabRect.width / 2 + el.scrollBy({ left: centerOffset, behavior: 'smooth' }) + } + }, [activeTabId]) + + return ( + <div className="relative shrink-0 h-tabs"> + {showLeftFade && ( + <div className="absolute left-0 top-0 bottom-0 w-6 z-10 pointer-events-none bg-gradient-to-r from-panel-bg to-transparent" /> + )} + {showRightFade && ( + <div className="absolute right-0 top-0 bottom-0 w-6 z-10 pointer-events-none bg-gradient-to-l from-panel-bg to-transparent" /> + )} + + <div + ref={scrollRef} + className="flex overflow-x-auto overflow-y-hidden h-full scroll-smooth" + role="tablist" + aria-label="Open files" + > + {tabs.length === 0 && ( + <div className="flex items-center px-3 text-ui-sm text-text-muted shrink-0"> + No files open + </div> + )} + + {tabs.map((tab) => { + const isActive = tab.id === activeTabId + + return ( + <div + key={tab.id} + ref={isActive ? activeTabRef : undefined} + role="tab" + aria-selected={isActive ? 'true' : 'false'} + aria-label={`${tab.name}${tab.modified ? ' (modified)' : ''}`} + className={`group relative flex items-center gap-1.5 px-4 py-2 text-ui-sm cursor-pointer shrink-0 transition-colors duration-100 select-none border-0 ${ + isActive + ? 'bg-editor-bg text-text-primary' + : 'bg-panel-bg text-text-muted hover:bg-panel-hover hover:text-text-secondary' + }`} + onClick={() => setActiveTab(tab.id)} + onContextMenu={(e) => handleTabContextMenu(e, tab.id)} + > + {isActive && ( + <span className="absolute top-0 left-0 right-0 h-[2px] bg-accent rounded-b-sm" /> + )} + + <span className="truncate max-w-32">{tab.name}</span> + + {tab.modified && ( + <span className="w-[6px] h-[6px] rounded-full shrink-0 bg-text-muted group-hover:hidden" /> + )} + + <button + className={`flex items-center justify-center w-[18px] h-[18px] rounded-sm transition-all duration-100 -mr-1 shrink-0 hover:bg-panel-border text-text-muted hover:text-text-primary ${ + tab.modified ? 'hidden group-hover:flex' : 'opacity-0 group-hover:opacity-100' + }`} + onClick={(e) => { + e.stopPropagation() + closeTab(tab.id) + }} + > + <CloseIcon /> + </button> + </div> + ) + })} + </div> + </div> + ) +} diff --git a/src/components/TitleBar/TitleBar.tsx b/src/components/TitleBar/TitleBar.tsx new file mode 100644 index 0000000..6a491df --- /dev/null +++ b/src/components/TitleBar/TitleBar.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from 'react' +import { appWindow } from '@tauri-apps/api/window' + +function MinimizeIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> + <line x1="2" y1="10" x2="10" y2="10" /> + </svg> + ) +} + +function MaximizeIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> + <rect x="1.5" y="1.5" width="9" height="9" rx="1" /> + </svg> + ) +} + +function RestoreIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> + <rect x="3" y="0.5" width="8" height="8" rx="1" fill="var(--color-editor-bg)" /> + <rect x="0.5" y="3" width="8" height="8" rx="1" /> + </svg> + ) +} + +function CloseIcon() { + return ( + <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"> + <line x1="2" y1="2" x2="10" y2="10" /> + <line x1="10" y1="2" x2="2" y2="10" /> + </svg> + ) +} + +export default function TitleBar() { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + appWindow.isMaximized().then(setIsMaximized) + + let unlisten: (() => void) | undefined + appWindow.onResized(() => { + appWindow.isMaximized().then(setIsMaximized) + }).then((fn) => { unlisten = fn }) + + return () => { unlisten?.() } + }, []) + + return ( + <div + className="flex items-center h-titlebar bg-editor-bg shrink-0 select-none" + data-tauri-drag-region + > + <div className="flex items-center gap-2 px-3 text-ui-sm font-medium text-text-secondary shrink-0" data-tauri-drag-region> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"> + <polyline points="16 18 22 12 16 6" /> + <polyline points="8 6 2 12 8 18" /> + </svg> + YACE + </div> + + <div className="flex-1" data-tauri-drag-region /> + + <div className="flex h-full" data-tauri-drag-region=""> + <button + onClick={() => appWindow.minimize()} + className="flex items-center justify-center w-[46px] h-full text-text-muted transition-colors duration-fast hover:bg-white/10 hover:text-text-primary active:bg-white/5" + aria-label="Minimize" + > + <MinimizeIcon /> + </button> + + <button + onClick={() => appWindow.toggleMaximize()} + className="flex items-center justify-center w-[46px] h-full text-text-muted transition-colors duration-fast hover:bg-white/10 hover:text-text-primary active:bg-white/5" + aria-label={isMaximized ? 'Restore' : 'Maximize'} + > + {isMaximized ? <RestoreIcon /> : <MaximizeIcon />} + </button> + + <button + onClick={() => appWindow.close()} + className="flex items-center justify-center w-[46px] h-full text-text-muted transition-colors duration-fast hover:bg-[#e81123] hover:text-white active:bg-[#bf0f1d]" + aria-label="Close" + > + <CloseIcon /> + </button> + </div> + </div> + ) +} diff --git a/src/components/Toasts/Toasts.tsx b/src/components/Toasts/Toasts.tsx new file mode 100644 index 0000000..ef3aa0d --- /dev/null +++ b/src/components/Toasts/Toasts.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import { useToastStore, type ToastType } from '@/stores/useToastStore' + +const typeColors: Record<ToastType, string> = { + success: 'var(--color-semantic-success)', + error: 'var(--color-semantic-error)', + info: 'var(--color-semantic-info)', + warning: 'var(--color-semantic-warning)', +} + +const typeIcons: Record<ToastType, string> = { + success: '✓', + error: '✗', + info: 'i', + warning: '!', +} + +function ToastItem({ id, message, type }: { id: string; message: string; type: ToastType }) { + const [exiting, setExiting] = useState(false) + const removeToast = useToastStore((s) => s.removeToast) + const color = typeColors[type] + + useEffect(() => { + const exitTimer = setTimeout(() => setExiting(true), 3000) + return () => clearTimeout(exitTimer) + }, []) + + return ( + <div + className={`flex items-start gap-2.5 min-w-[280px] max-w-[400px] px-3 py-2.5 rounded-lg shadow-glass-lg toast${exiting ? ' toast-exit' : ''}`} + style={{ + backgroundColor: 'var(--color-panel-bg)', + border: '1px solid var(--color-panel-border)', + borderLeft: `3px solid ${color}`, + }} + role="alert" + onAnimationEnd={() => { if (exiting) removeToast(id) }} + > + <span + className="flex items-center justify-center w-[18px] h-[18px] mt-[1px] rounded-full text-[10px] font-bold shrink-0" + style={{ backgroundColor: color, color: '#fff' }} + > + {typeIcons[type]} + </span> + <span className="flex-1 text-ui-sm text-text-primary leading-[1.4]">{message}</span> + <button + className="shrink-0 text-text-muted hover:text-text-primary transition-colors duration-fast" + onClick={() => removeToast(id)} + > + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> + <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> + </svg> + </button> + </div> + ) +} + +export default function Toasts() { + const toasts = useToastStore((s) => s.toasts) + + if (toasts.length === 0) return null + + return ( + <div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2 pointer-events-none"> + {toasts.map((t) => ( + <div key={t.id} className="pointer-events-auto"> + <ToastItem id={t.id} message={t.message} type={t.type} /> + </div> + ))} + </div> + ) +} diff --git a/src/components/UI/Input.tsx b/src/components/UI/Input.tsx new file mode 100644 index 0000000..e6a84c0 --- /dev/null +++ b/src/components/UI/Input.tsx @@ -0,0 +1,23 @@ +interface InputProps { + value: string | number + onChange: (value: string) => void + placeholder?: string + type?: string + min?: number + max?: number +} + +export default function Input({ value, onChange, placeholder, type = 'text', min, max }: InputProps) { + return ( + <input + type={type} + value={value} + placeholder={placeholder} + min={min} + max={max} + onChange={(e) => onChange(e.target.value)} + className="w-full h-7 px-2 rounded-md text-ui-sm text-text-primary bg-editor-surface border transition-all duration-150 outline-none placeholder:text-text-muted focus:border-accent" + style={{ borderColor: 'var(--color-panel-border)' }} + /> + ) +} diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx new file mode 100644 index 0000000..22a65ee --- /dev/null +++ b/src/components/UI/Slider.tsx @@ -0,0 +1,74 @@ +import { useRef, useCallback, useEffect, useState } from 'react' + +interface SliderProps { + value: number + min: number + max: number + step?: number + onChange: (value: number) => void +} + +export default function Slider({ value, min, max, step = 1, onChange }: SliderProps) { + const trackRef = useRef<HTMLDivElement>(null) + const [dragging, setDragging] = useState(false) + + const pct = ((value - min) / (max - min)) * 100 + + const updateFromClient = useCallback((clientX: number) => { + const rect = trackRef.current?.getBoundingClientRect() + if (!rect) return + const raw = (clientX - rect.left) / rect.width + const clamped = Math.max(0, Math.min(1, raw)) + const stepped = Math.round((min + clamped * (max - min)) / step) * step + onChange(Math.max(min, Math.min(max, stepped))) + }, [min, max, step, onChange]) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setDragging(true) + updateFromClient(e.clientX) + }, [updateFromClient]) + + useEffect(() => { + if (!dragging) return + const handleMove = (e: MouseEvent) => { e.preventDefault(); updateFromClient(e.clientX) } + const handleUp = () => setDragging(false) + window.addEventListener('mousemove', handleMove) + window.addEventListener('mouseup', handleUp) + return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp) } + }, [dragging, updateFromClient]) + + return ( + <div + ref={trackRef} + className="relative h-4 w-full cursor-pointer flex items-center" + onMouseDown={handleMouseDown} + role="slider" + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); onChange(Math.min(max, value + step)) } + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); onChange(Math.max(min, value - step)) } + }} + > + <div className="absolute h-[3px] w-full rounded-full" style={{ backgroundColor: 'var(--color-panel-border)' }}> + <div + className="absolute h-full rounded-full transition-[width] duration-75" + style={{ width: `${pct}%`, backgroundColor: 'var(--color-accent)' }} + /> + </div> + <div + className="absolute w-3.5 h-3.5 rounded-full shadow-sm border-2 transition-transform duration-75" + style={{ + left: `${pct}%`, + marginLeft: '-7px', + backgroundColor: dragging ? 'var(--color-accent)' : 'var(--color-panel-bg)', + borderColor: 'var(--color-accent)', + transform: dragging ? 'scale(1.15)' : 'scale(1)', + }} + /> + </div> + ) +} diff --git a/src/components/UI/Toggle.tsx b/src/components/UI/Toggle.tsx new file mode 100644 index 0000000..17b98e2 --- /dev/null +++ b/src/components/UI/Toggle.tsx @@ -0,0 +1,25 @@ +interface ToggleProps { + checked: boolean + onChange: (checked: boolean) => void + disabled?: boolean +} + +export default function Toggle({ checked, onChange, disabled }: ToggleProps) { + return ( + <button + role="switch" + aria-checked={checked} + disabled={disabled} + className="relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 disabled:opacity-30 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-transparent" + style={{ + backgroundColor: checked ? 'var(--color-accent)' : 'var(--color-panel-border)', + }} + onClick={() => { if (!disabled) onChange(!checked) }} + > + <span + className="inline-block h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform duration-200" + style={{ transform: checked ? 'translateX(18px)' : 'translateX(3px)', marginTop: '2px' }} + /> + </button> + ) +} diff --git a/src/components/Welcome/WelcomeScreen.tsx b/src/components/Welcome/WelcomeScreen.tsx new file mode 100644 index 0000000..13b5980 --- /dev/null +++ b/src/components/Welcome/WelcomeScreen.tsx @@ -0,0 +1,59 @@ +const shortcuts = [ + { keys: ['Ctrl', 'N'], label: 'New File' }, + { keys: ['Ctrl', 'O'], label: 'Open File' }, + { keys: ['Ctrl', 'P'], label: 'Command Palette' }, +] + +function Kbd({ keys }: { keys: string[] }) { + return ( + <span className="inline-flex items-center gap-0.5"> + {keys.map((key, i) => ( + <span key={i}> + {i > 0 && <span className="mx-0.5 text-text-muted text-ui-xs">+</span>} + <kbd + className="inline-flex items-center justify-center min-w-[22px] h-[18px] px-[3px] rounded text-[10px] font-mono font-medium leading-none bg-editor-surface text-text-primary border" + style={{ borderColor: 'var(--color-panel-border)', boxShadow: '0 1px 0 var(--color-panel-border)' }} + > + {key} + </kbd> + </span> + ))} + </span> + ) +} + +export default function WelcomeScreen() { + return ( + <div className="flex-1 flex flex-col items-center justify-center select-none overflow-hidden"> + <div className="flex flex-col items-center gap-1 mb-6"> + <span + className="text-[40px] font-bold leading-none tracking-tight bg-clip-text text-transparent select-none" + style={{ + backgroundImage: 'linear-gradient(135deg, var(--color-accent) 0%, var(--color-text-muted) 100%)', + opacity: 0.2, + }} + > + YACE + </span> + <span + className="text-[11px] leading-none text-text-muted select-none" + style={{ opacity: 0.35 }} + > + Yet Another Code Editor + </span> + </div> + + <div className="flex flex-col gap-2"> + {shortcuts.map((item) => ( + <div + key={item.label} + className="flex items-center gap-3 px-3 py-1.5 rounded-md transition-all duration-150 hover:bg-panel-hover/40" + > + <Kbd keys={item.keys} /> + <span className="text-ui-sm text-text-secondary">{item.label}</span> + </div> + ))} + </div> + </div> + ) +} diff --git a/src/hooks/useCursorPosition.ts b/src/hooks/useCursorPosition.ts new file mode 100644 index 0000000..479598d --- /dev/null +++ b/src/hooks/useCursorPosition.ts @@ -0,0 +1,8 @@ +import { useTabsStore } from '@/stores/useTabsStore' + +export function useCursorPosition() { + const cursorLine = useTabsStore((s) => s.cursorLine) + const cursorCol = useTabsStore((s) => s.cursorCol) + + return { cursorLine, cursorCol } +} diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts new file mode 100644 index 0000000..b1ef390 --- /dev/null +++ b/src/hooks/useKeyboard.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react' +import { useTabsStore } from '@/stores/useTabsStore' +import { useFilesStore } from '@/stores/useFilesStore' +import { useLayoutStore } from '@/stores/useLayoutStore' +import { useCommandStore } from '@/stores/useCommandStore' + +export function useKeyboard() { + const activeTabId = useTabsStore((s) => s.activeTabId) + const saveFile = useTabsStore((s) => s.saveFile) + const closeTab = useTabsStore((s) => s.closeTab) + const tabs = useTabsStore((s) => s.tabs) + const setActiveTab = useTabsStore((s) => s.setActiveTab) + const createUntitledFile = useTabsStore((s) => s.createUntitledFile) + const pickAndOpenFile = useTabsStore((s) => s.pickAndOpenFile) + const openFolder = useFilesStore((s) => s.openFolder) + const toggleSidePanel = useLayoutStore((s) => s.toggleSidePanel) + const toggleCommandPalette = useCommandStore((s) => s.toggle) + + useEffect(() => { + async function handleKeyDown(e: KeyboardEvent) { + const isMod = e.ctrlKey || e.metaKey + if (!isMod) return + + if (e.key === 'p') { + e.preventDefault() + toggleCommandPalette() + return + } + + if (e.key === 'n') { + e.preventDefault() + createUntitledFile() + return + } + + if (e.key === 'o' && e.shiftKey) { + e.preventDefault() + openFolder() + return + } + + if (e.key === 'o') { + e.preventDefault() + pickAndOpenFile() + return + } + + if (e.key === 's') { + e.preventDefault() + if (activeTabId) saveFile(activeTabId) + return + } + + if (e.key === 'w') { + e.preventDefault() + if (activeTabId) closeTab(activeTabId) + return + } + + if (e.key === 'b') { + e.preventDefault() + toggleSidePanel() + return + } + + if (e.shiftKey && e.key === 'Tab') { + e.preventDefault() + if (tabs.length > 1 && activeTabId) { + const idx = tabs.findIndex((t) => t.id === activeTabId) + const prevIdx = idx === 0 ? tabs.length - 1 : idx - 1 + setActiveTab(tabs[prevIdx].id) + } + return + } + + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault() + if (tabs.length > 1 && activeTabId) { + const idx = tabs.findIndex((t) => t.id === activeTabId) + const nextIdx = idx === tabs.length - 1 ? 0 : idx + 1 + setActiveTab(tabs[nextIdx].id) + } + return + } + + if (e.key === 'k' && e.shiftKey) { + e.preventDefault() + openFolder() + } + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [activeTabId, saveFile, closeTab, tabs, setActiveTab, createUntitledFile, pickAndOpenFile, openFolder, toggleSidePanel, toggleCommandPalette]) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..da13c1f --- /dev/null +++ b/src/index.css @@ -0,0 +1,248 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --sidebar-width: 240px; + --tabs-height: 36px; + --status-height: 24px; + + --color-editor-bg: #0c0c0e; + --color-editor-surface: #121216; + --color-editor-line: #19191d; + + --color-panel-bg: #141417; + --color-panel-hover: #1c1c21; + --color-panel-active: #24242b; + --color-activity-bg: #08080a; + --color-panel-border: rgba(255, 255, 255, 0.03); + + --color-accent: #f0a030; + --color-accent-hover: #f5b84a; + --color-accent-muted: rgba(240, 160, 48, 0.2); + --color-accent-text: #f5c56b; + + --color-text-primary: #e8e8ed; + --color-text-secondary: #9b9ba3; + --color-text-muted: #7a7a82; + + --color-semantic-error: #e34c4c; + --color-semantic-warning: #f0a030; + --color-semantic-info: #42a5f5; + --color-semantic-success: #4caf50; + + --color-ruler: rgba(255, 255, 255, 0.06); + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.35); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.35), 0 2px 4px -2px rgba(0,0,0,0.25); + --shadow-lg: 0 4px 6px rgba(0,0,0,0.3), 0 10px 30px rgba(0,0,0,0.2); + --shadow-xl: 0 8px 12px rgba(0,0,0,0.3), 0 18px 50px rgba(0,0,0,0.18); + --shadow-2xl: 0 12px 24px rgba(0,0,0,0.3), 0 24px 64px rgba(0,0,0,0.18); + } + + [data-theme="light"] { + --color-editor-bg: #fafbfc; + --color-editor-surface: #f0f2f5; + --color-editor-line: #e8eaed; + + --color-panel-bg: #f3f4f6; + --color-panel-hover: #e5e7eb; + --color-panel-active: #d1d5db; + --color-activity-bg: #e8eaed; + --color-panel-border: rgba(0, 0, 0, 0.03); + + --color-accent: #d97706; + --color-accent-hover: #b45309; + --color-accent-muted: rgba(217, 119, 6, 0.15); + --color-accent-text: #92400e; + + --color-text-primary: #111827; + --color-text-secondary: #4b5563; + --color-text-muted: #6b7280; + + --color-semantic-error: #dc2626; + --color-semantic-warning: #d97706; + --color-semantic-info: #2563eb; + --color-semantic-success: #16a34a; + + --color-ruler: rgba(0, 0, 0, 0.06); + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(0,0,0,0.04); + --shadow-lg: 0 4px 6px rgba(0,0,0,0.05), 0 10px 30px rgba(0,0,0,0.04); + --shadow-xl: 0 8px 12px rgba(0,0,0,0.05), 0 18px 50px rgba(0,0,0,0.04); + --shadow-2xl: 0 12px 24px rgba(0,0,0,0.06), 0 24px 64px rgba(0,0,0,0.05); + } + + html, body, #root { + @apply m-0 p-0 h-full overflow-hidden; + } + + html { + overscroll-behavior: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + scrollbar-width: thin; + } + + body { + @apply font-ui antialiased; + background-color: var(--color-editor-bg); + color: var(--color-text-primary); + user-select: none; + -webkit-user-select: none; + } + + body, body * { + transition: background-color 0.25s ease, + color 0.25s ease, + border-color 0.25s ease, + box-shadow 0.25s ease; + } + + * { + -webkit-tap-highlight-color: transparent; + } + + svg, img { + -webkit-user-drag: none; + user-select: none; + } + + .cm-editor, .cm-editor * { + user-select: text; + -webkit-user-select: text; + } + + .cm-editor { + line-height: 1.6; + letter-spacing: 0; + font-variant-ligatures: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .cm-scroller { + scroll-behavior: auto; + } + + .cm-content { + padding: 0; + } + + .cm-line { + padding: 0; + } + + *:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; + } + + .focus-ring:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + + *::selection { + background-color: var(--color-accent-muted); + } + + *::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: var(--color-panel-border); + border-radius: 4px; + transition: opacity 0.2s ease, background-color 0.15s ease; + opacity: 0; + } + + *:hover::-webkit-scrollbar-thumb { + opacity: 0.7; + } + + *::-webkit-scrollbar-thumb:hover { + opacity: 1; + background: var(--color-text-muted); + } + + *::-webkit-scrollbar-thumb:active { + opacity: 1; + background: var(--color-text-secondary); + } + + .cm-scroller::-webkit-scrollbar-thumb { + opacity: 0.4; + } + + .cm-scroller:hover::-webkit-scrollbar-thumb { + opacity: 0.8; + } + + .shadow-glass { + box-shadow: var(--shadow-2xl); + } + + .shadow-glass-lg { + box-shadow: var(--shadow-lg); + } + + .panel-mica { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 40%), + var(--color-panel-bg); + } + + [data-theme="light"] .panel-mica { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.6) 0%, transparent 40%), + var(--color-panel-bg); + } + + .backdrop-glass { + background-color: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + + [data-theme="light"] .backdrop-glass { + background-color: rgba(0, 0, 0, 0.08); + } + + @keyframes contextMenuIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } + } + + .context-menu { + animation: contextMenuIn 0.12s ease-out; + transform-origin: top left; + } + + @keyframes toastIn { + from { opacity: 0; transform: translateY(16px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + @keyframes toastOut { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(8px) scale(0.96); } + } + + .toast { + animation: toastIn 0.25s ease-out; + } + + .toast.toast-exit { + animation: toastOut 0.2s ease-in forwards; + } +} diff --git a/src/lib/languageMap.ts b/src/lib/languageMap.ts new file mode 100644 index 0000000..ec8d6b7 --- /dev/null +++ b/src/lib/languageMap.ts @@ -0,0 +1,23 @@ +import type { Language } from '@/types' + +const extensionMap: Record<string, Language> = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + rs: 'rust', + json: 'json', + md: 'markdown', + mdx: 'markdown', + html: 'html', + htm: 'html', + css: 'css', + py: 'python', +} + +export function detectLanguage(filename: string): Language { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + return extensionMap[ext] ?? 'plaintext' +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts new file mode 100644 index 0000000..294f117 --- /dev/null +++ b/src/lib/tauri.ts @@ -0,0 +1,46 @@ +import { invoke } from '@tauri-apps/api/tauri' +import type { FileEntry } from '@/types' + +export async function readFile(path: string): Promise<string> { + return invoke<string>('read_file', { path }) +} + +export async function writeFile(path: string, content: string): Promise<void> { + return invoke<void>('write_file', { path, content }) +} + +export async function openDir(path: string): Promise<FileEntry[]> { + return invoke<FileEntry[]>('open_dir', { path }) +} + +export async function pickDirectory(): Promise<string | null> { + const { open } = await import('@tauri-apps/api/dialog') + const selected = await open({ + directory: true, + multiple: false, + title: 'Open Folder', + }) + return selected as string | null +} + +export async function createDir(path: string): Promise<void> { + return invoke<void>('create_dir', { path }) +} + +export async function pickFile(): Promise<string | null> { + const { open } = await import('@tauri-apps/api/dialog') + const selected = await open({ + multiple: false, + title: 'Open File', + }) + return (selected as string) ?? null +} + +export async function saveDialog(defaultName?: string): Promise<string | null> { + const { save } = await import('@tauri-apps/api/dialog') + const selected = await save({ + defaultPath: defaultName, + title: 'Save File', + }) + return selected ?? null +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + <React.StrictMode> + <App /> + </React.StrictMode>, +) diff --git a/src/stores/useCommandStore.ts b/src/stores/useCommandStore.ts new file mode 100644 index 0000000..2975436 --- /dev/null +++ b/src/stores/useCommandStore.ts @@ -0,0 +1,137 @@ +import { create } from 'zustand' +import { useTabsStore } from './useTabsStore' +import { useFilesStore } from './useFilesStore' +import { useLayoutStore } from './useLayoutStore' + +export interface Command { + id: string + label: string + shortcut?: string + category: string + action: () => void +} + +export interface CommandState { + isOpen: boolean + query: string + selectedIndex: number + commands: Command[] + open: () => void + close: () => void + toggle: () => void + setQuery: (query: string) => void + setSelectedIndex: (index: number) => void + executeSelected: () => void +} + +function buildCommands(): Command[] { + const filesStore = useFilesStore.getState() + const layoutStore = useLayoutStore.getState() + + return [ + { + id: 'open-folder', + label: 'Open Folder', + shortcut: 'Ctrl+Shift+K', + category: 'File', + action: () => filesStore.openFolder(), + }, + { + id: 'toggle-sidebar', + label: 'Toggle Sidebar', + shortcut: 'Ctrl+B', + category: 'View', + action: () => layoutStore.toggleSidePanel(), + }, + { + id: 'save-file', + label: 'Save File', + shortcut: 'Ctrl+S', + category: 'File', + action: () => { + const state = useTabsStore.getState() + if (state.activeTabId) state.saveFile(state.activeTabId) + }, + }, + { + id: 'close-tab', + label: 'Close Current Tab', + shortcut: 'Ctrl+W', + category: 'File', + action: () => { + const state = useTabsStore.getState() + if (state.activeTabId) state.closeTab(state.activeTabId) + }, + }, + { + id: 'close-all-tabs', + label: 'Close All Tabs', + shortcut: '', + category: 'File', + action: () => { + const state = useTabsStore.getState() + state.tabs.forEach((t) => state.closeTab(t.id)) + }, + }, + { + id: 'show-explorer', + label: 'Focus Explorer', + shortcut: '', + category: 'View', + action: () => layoutStore.setActiveView('explorer'), + }, + { + id: 'show-search', + label: 'Show Search', + shortcut: '', + category: 'View', + action: () => layoutStore.setActiveView('search'), + }, + ] +} + +export const useCommandStore = create<CommandState>((set, get) => ({ + isOpen: false, + query: '', + selectedIndex: 0, + commands: [], + + open: () => { + set({ isOpen: true, query: '', selectedIndex: 0, commands: buildCommands() }) + }, + + close: () => { + set({ isOpen: false, query: '', selectedIndex: 0 }) + }, + + toggle: () => { + const { isOpen } = get() + if (isOpen) { + get().close() + } else { + get().open() + } + }, + + setQuery: (query: string) => { + set({ query, selectedIndex: 0 }) + }, + + setSelectedIndex: (index: number) => { + set({ selectedIndex: index }) + }, + + executeSelected: () => { + const { commands, query, selectedIndex } = get() + const filtered = commands.filter( + (c) => + c.label.toLowerCase().includes(query.toLowerCase()) || + c.category.toLowerCase().includes(query.toLowerCase()) + ) + const cmd = filtered[selectedIndex] + if (cmd) { + cmd.action() + get().close() + } + }, +})) diff --git a/src/stores/useContextMenuStore.ts b/src/stores/useContextMenuStore.ts new file mode 100644 index 0000000..a0fd2cf --- /dev/null +++ b/src/stores/useContextMenuStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' + +export type ContextMenuIcon = 'copy' | 'cut' | 'paste' | 'undo' | 'redo' | 'selectAll' | 'close' | 'folder' | 'file' | 'link' | 'rename' | 'trash' + +export interface ContextMenuItem { + id: string + label: string + shortcut?: string + icon?: ContextMenuIcon + action: () => void + disabled?: boolean + separator?: boolean +} + +interface ContextMenuState { + isOpen: boolean + x: number + y: number + items: ContextMenuItem[] + open: (x: number, y: number, items: ContextMenuItem[]) => void + close: () => void +} + +export const useContextMenuStore = create<ContextMenuState>((set) => ({ + isOpen: false, + x: 0, + y: 0, + items: [], + open: (x, y, items) => set({ isOpen: true, x, y, items }), + close: () => set({ isOpen: false }), +})) diff --git a/src/stores/useFilesStore.ts b/src/stores/useFilesStore.ts new file mode 100644 index 0000000..200fb68 --- /dev/null +++ b/src/stores/useFilesStore.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand' +import type { FileEntry } from '@/types' +import { openDir, pickDirectory, writeFile, createDir } from '@/lib/tauri' + +interface FilesState { + fileTree: FileEntry[] + rootPath: string | null + loading: boolean + openFolder: () => Promise<void> + loadFolder: (path: string) => Promise<void> + createFile: (parentPath?: string) => Promise<string | null> + createFolder: (parentPath?: string) => Promise<string | null> +} + +function generateName(base: string, existing: string[]): string { + const used = new Set(existing) + let name = base + let counter = 1 + while (used.has(name)) { + name = `${base} ${counter++}` + } + return name +} + +function collectNames(entries: FileEntry[]): string[] { + const names: string[] = [] + for (const e of entries) { + names.push(e.name) + } + return names +} + +export const useFilesStore = create<FilesState>((set, get) => ({ + fileTree: [], + rootPath: null, + loading: false, + + openFolder: async () => { + const path = await pickDirectory() + if (!path) return + set({ rootPath: path, loading: true }) + localStorage.setItem('yace_last_folder', path) + try { + const entries = await openDir(path) + set({ fileTree: entries, loading: false }) + } catch { + set({ loading: false }) + } + }, + + loadFolder: async (path: string) => { + set({ loading: true }) + try { + const entries = await openDir(path) + set({ fileTree: entries, rootPath: path, loading: false }) + } catch { + set({ loading: false }) + } + }, + + createFile: async (parentPath?: string): Promise<string | null> => { + const { rootPath, fileTree } = get() + if (!rootPath) return null + const targetDir = parentPath ?? rootPath + const name = generateName('Untitled-1', collectNames(fileTree)) + const filePath = `${targetDir}\\${name}` + await writeFile(filePath, '') + const entries = await openDir(rootPath) + set({ fileTree: entries }) + return filePath + }, + + createFolder: async (parentPath?: string): Promise<string | null> => { + const { rootPath, fileTree } = get() + if (!rootPath) return null + const targetDir = parentPath ?? rootPath + const name = generateName('New Folder', collectNames(fileTree)) + const dirPath = `${targetDir}\\${name}` + await createDir(dirPath) + const entries = await openDir(rootPath) + set({ fileTree: entries }) + return dirPath + }, +})) diff --git a/src/stores/useLayoutStore.ts b/src/stores/useLayoutStore.ts new file mode 100644 index 0000000..adcdae7 --- /dev/null +++ b/src/stores/useLayoutStore.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand' + +export type ActivityView = 'explorer' | 'search' | 'extensions' | 'settings' + +interface LayoutState { + activeView: ActivityView | null + sidePanelVisible: boolean + sidePanelWidth: number + setActiveView: (view: ActivityView | null) => void + toggleSidePanel: () => void + showSidePanel: () => void + hideSidePanel: () => void + setSidePanelWidth: (width: number) => void +} + +const SIDEPANEL_MIN = 180 +const SIDEPANEL_MAX = 500 +const SIDEPANEL_DEFAULT = 240 + +export const useLayoutStore = create<LayoutState>((set, get) => ({ + activeView: 'explorer', + sidePanelVisible: true, + sidePanelWidth: SIDEPANEL_DEFAULT, + + setActiveView: (view: ActivityView | null) => { + const { activeView, hideSidePanel, showSidePanel } = get() + if (activeView === view) { + hideSidePanel() + set({ activeView: null }) + } else { + set({ activeView: view }) + showSidePanel() + } + }, + + toggleSidePanel: () => { + set((s) => ({ sidePanelVisible: !s.sidePanelVisible })) + }, + + showSidePanel: () => { + set({ sidePanelVisible: true }) + }, + + hideSidePanel: () => { + set({ sidePanelVisible: false }) + }, + + setSidePanelWidth: (width: number) => { + set({ sidePanelWidth: Math.max(SIDEPANEL_MIN, Math.min(SIDEPANEL_MAX, width)) }) + }, +})) diff --git a/src/stores/useSettingsStore.ts b/src/stores/useSettingsStore.ts new file mode 100644 index 0000000..7f2be4f --- /dev/null +++ b/src/stores/useSettingsStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface SettingsState { + fontSize: number + tabSize: number + fontFamily: string + wordWrap: boolean + lineNumbers: boolean + ruler: boolean + rulerColumn: number + autoSave: boolean + autoSaveDelay: number + + set: <K extends keyof SettingsState>(key: K, value: SettingsState[K]) => void +} + +export const useSettingsStore = create<SettingsState>()( + persist( + (set) => ({ + fontSize: 14, + tabSize: 2, + fontFamily: 'JetBrains Mono', + wordWrap: true, + lineNumbers: true, + ruler: true, + rulerColumn: 80, + autoSave: false, + autoSaveDelay: 2000, + set: (key, value) => set({ [key]: value }), + }), + { name: 'yace-settings' } + ) +) diff --git a/src/stores/useTabsStore.ts b/src/stores/useTabsStore.ts new file mode 100644 index 0000000..eb9a03a --- /dev/null +++ b/src/stores/useTabsStore.ts @@ -0,0 +1,152 @@ +import { create } from 'zustand' +import type { Tab } from '@/types' +import { readFile, writeFile, saveDialog, pickFile } from '@/lib/tauri' +import { detectLanguage } from '@/lib/languageMap' + +interface TabsState { + tabs: Tab[] + activeTabId: string | null + cursorLine: number + cursorCol: number + savingId: string | null + statusMessage: string | null + untitledCounter: number + + openFile: (path: string) => Promise<void> + pickAndOpenFile: () => Promise<void> + createUntitledFile: () => void + closeTab: (id: string) => void + setActiveTab: (id: string) => void + updateContent: (id: string, content: string) => void + saveFile: (id: string) => Promise<void> + setCursorPosition: (line: number, col: number) => void + clearStatusMessage: () => void +} + +function isUntitled(id: string) { + return id.startsWith('untitled://') +} + +export const useTabsStore = create<TabsState>((set, get) => ({ + tabs: [], + activeTabId: null, + cursorLine: 1, + cursorCol: 1, + savingId: null, + statusMessage: null, + untitledCounter: 0, + + openFile: async (path: string) => { + const { tabs } = get() + const existing = tabs.find((t) => t.path === path) + if (existing) { + set({ activeTabId: existing.id }) + return + } + + try { + const content = await readFile(path) + const name = path.split(/[\\/]/).pop() ?? 'untitled' + const id = path + const tab: Tab = { + id, + path, + name, + language: detectLanguage(name), + content, + modified: false, + } + set({ tabs: [...tabs, tab], activeTabId: id }) + } catch (err) { + console.error('Failed to open file:', err) + } + }, + + pickAndOpenFile: async () => { + const path = await pickFile() + if (path) get().openFile(path) + }, + + createUntitledFile: () => { + const { tabs, untitledCounter } = get() + const n = untitledCounter + 1 + const name = `Untitled-${n}` + const id = `untitled://${n}` + const tab: Tab = { + id, + path: id, + name, + language: 'plaintext', + content: '', + modified: false, + } + set({ tabs: [...tabs, tab], activeTabId: id, untitledCounter: n }) + }, + + closeTab: (id: string) => { + const { tabs, activeTabId } = get() + const newTabs = tabs.filter((t) => t.id !== id) + let newActive = activeTabId + if (activeTabId === id) { + const idx = tabs.findIndex((t) => t.id === id) + newActive = newTabs[Math.min(idx, newTabs.length - 1)]?.id ?? null + } + set({ tabs: newTabs, activeTabId: newActive }) + }, + + setActiveTab: (id: string) => { + set({ activeTabId: id }) + }, + + updateContent: (id: string, content: string) => { + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === id ? { ...t, content, modified: true } : t + ), + })) + }, + + saveFile: async (id: string) => { + const { tabs } = get() + const tab = tabs.find((t) => t.id === id) + if (!tab) return + + set({ savingId: id, statusMessage: null }) + + try { + let targetPath = tab.path + + if (isUntitled(targetPath)) { + const picked = await saveDialog(tab.name) + if (!picked) { + set({ savingId: null }) + return + } + targetPath = picked + } + + await writeFile(targetPath, tab.content) + const name = targetPath.split(/[\\/]/).pop() ?? tab.name + set({ + tabs: tabs.map((t) => + t.id === id + ? { ...t, path: targetPath, name, language: detectLanguage(name), modified: false } + : t + ), + savingId: null, + statusMessage: 'Saved', + }) + } catch (err) { + set({ savingId: null, statusMessage: 'Save failed' }) + console.error('Failed to save file:', err) + } + }, + + setCursorPosition: (line: number, col: number) => { + set({ cursorLine: line, cursorCol: col }) + }, + + clearStatusMessage: () => { + set({ statusMessage: null }) + }, +})) diff --git a/src/stores/useThemeStore.ts b/src/stores/useThemeStore.ts new file mode 100644 index 0000000..d9c3d06 --- /dev/null +++ b/src/stores/useThemeStore.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand' + +export type Theme = 'dark' | 'light' + +interface ThemeState { + theme: Theme + setTheme: (theme: Theme) => void + toggleTheme: () => void +} + +function getInitialTheme(): Theme { + const stored = localStorage.getItem('yace_theme') + if (stored === 'light' || stored === 'dark') return stored + return 'dark' +} + +function applyTheme(theme: Theme) { + document.documentElement.setAttribute('data-theme', theme) +} + +const initial = getInitialTheme() +applyTheme(initial) + +export const useThemeStore = create<ThemeState>((set) => ({ + theme: initial, + + setTheme: (theme: Theme) => { + applyTheme(theme) + localStorage.setItem('yace_theme', theme) + set({ theme }) + }, + + toggleTheme: () => { + set((state) => { + const next = state.theme === 'dark' ? 'light' : 'dark' + applyTheme(next) + localStorage.setItem('yace_theme', next) + return { theme: next } + }) + }, +})) diff --git a/src/stores/useToastStore.ts b/src/stores/useToastStore.ts new file mode 100644 index 0000000..ab45325 --- /dev/null +++ b/src/stores/useToastStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' + +export type ToastType = 'success' | 'error' | 'info' | 'warning' + +export interface Toast { + id: string + message: string + type: ToastType +} + +interface ToastState { + toasts: Toast[] + addToast: (message: string, type?: ToastType, duration?: number) => void + removeToast: (id: string) => void +} + +let counter = 0 + +export const useToastStore = create<ToastState>((set, get) => ({ + toasts: [], + addToast: (message, type = 'info', duration = 3500) => { + const id = `toast-${++counter}` + set((s) => ({ toasts: [...s.toasts, { id, message, type }] })) + setTimeout(() => { + get().removeToast(id) + }, duration) + }, + removeToast: (id) => { + set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) + }, +})) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..3a822ae --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,26 @@ +export interface FileEntry { + name: string + path: string + is_dir: boolean + children?: FileEntry[] +} + +export interface Tab { + id: string + path: string + name: string + language: string + content: string + modified: boolean +} + +export type Language = + | 'typescript' + | 'javascript' + | 'rust' + | 'json' + | 'markdown' + | 'html' + | 'css' + | 'python' + | 'plaintext' diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> |