aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-06-05 20:52:54 +0000
commit09f964451d7d92e9891430ec4595c1276d486aab (patch)
tree28da4483f5c28924a8c47fceb648b1baebe88224 /src
downloadyace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz
yace-09f964451d7d92e9891430ec4595c1276d486aab.zip
feat: uploadHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx46
-rw-r--r--src/components/ActivityBar/ActivityBar.tsx147
-rw-r--r--src/components/Breadcrumbs/Breadcrumbs.tsx55
-rw-r--r--src/components/CommandPalette/CommandPalette.tsx201
-rw-r--r--src/components/ContextMenu/ContextMenu.tsx184
-rw-r--r--src/components/Editor/YaceEditor.tsx291
-rw-r--r--src/components/Settings/SettingsPanel.tsx106
-rw-r--r--src/components/SidePanel/SidePanel.tsx187
-rw-r--r--src/components/Sidebar/FileTree.tsx227
-rw-r--r--src/components/StatusBar/StatusBar.tsx163
-rw-r--r--src/components/TabsBar/TabsBar.tsx136
-rw-r--r--src/components/TitleBar/TitleBar.tsx94
-rw-r--r--src/components/Toasts/Toasts.tsx72
-rw-r--r--src/components/UI/Input.tsx23
-rw-r--r--src/components/UI/Slider.tsx74
-rw-r--r--src/components/UI/Toggle.tsx25
-rw-r--r--src/components/Welcome/WelcomeScreen.tsx59
-rw-r--r--src/hooks/useCursorPosition.ts8
-rw-r--r--src/hooks/useKeyboard.ts95
-rw-r--r--src/index.css248
-rw-r--r--src/lib/languageMap.ts23
-rw-r--r--src/lib/tauri.ts46
-rw-r--r--src/main.tsx10
-rw-r--r--src/stores/useCommandStore.ts137
-rw-r--r--src/stores/useContextMenuStore.ts31
-rw-r--r--src/stores/useFilesStore.ts84
-rw-r--r--src/stores/useLayoutStore.ts51
-rw-r--r--src/stores/useSettingsStore.ts34
-rw-r--r--src/stores/useTabsStore.ts152
-rw-r--r--src/stores/useThemeStore.ts41
-rw-r--r--src/stores/useToastStore.ts31
-rw-r--r--src/types/index.ts26
-rw-r--r--src/vite-env.d.ts1
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" />