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/components | |
| download | yace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz yace-09f964451d7d92e9891430ec4595c1276d486aab.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/ActivityBar/ActivityBar.tsx | 147 | ||||
| -rw-r--r-- | src/components/Breadcrumbs/Breadcrumbs.tsx | 55 | ||||
| -rw-r--r-- | src/components/CommandPalette/CommandPalette.tsx | 201 | ||||
| -rw-r--r-- | src/components/ContextMenu/ContextMenu.tsx | 184 | ||||
| -rw-r--r-- | src/components/Editor/YaceEditor.tsx | 291 | ||||
| -rw-r--r-- | src/components/Settings/SettingsPanel.tsx | 106 | ||||
| -rw-r--r-- | src/components/SidePanel/SidePanel.tsx | 187 | ||||
| -rw-r--r-- | src/components/Sidebar/FileTree.tsx | 227 | ||||
| -rw-r--r-- | src/components/StatusBar/StatusBar.tsx | 163 | ||||
| -rw-r--r-- | src/components/TabsBar/TabsBar.tsx | 136 | ||||
| -rw-r--r-- | src/components/TitleBar/TitleBar.tsx | 94 | ||||
| -rw-r--r-- | src/components/Toasts/Toasts.tsx | 72 | ||||
| -rw-r--r-- | src/components/UI/Input.tsx | 23 | ||||
| -rw-r--r-- | src/components/UI/Slider.tsx | 74 | ||||
| -rw-r--r-- | src/components/UI/Toggle.tsx | 25 | ||||
| -rw-r--r-- | src/components/Welcome/WelcomeScreen.tsx | 59 |
16 files changed, 2044 insertions, 0 deletions
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> + ) +} |