From 09f964451d7d92e9891430ec4595c1276d486aab Mon Sep 17 00:00:00 2001 From: zwlucas Date: Fri, 5 Jun 2026 17:52:54 -0300 Subject: feat: upload Signed-off-by: zwlucas --- src/App.tsx | 46 ++++ src/components/ActivityBar/ActivityBar.tsx | 147 ++++++++++++ src/components/Breadcrumbs/Breadcrumbs.tsx | 55 +++++ src/components/CommandPalette/CommandPalette.tsx | 201 ++++++++++++++++ src/components/ContextMenu/ContextMenu.tsx | 184 ++++++++++++++ src/components/Editor/YaceEditor.tsx | 291 +++++++++++++++++++++++ src/components/Settings/SettingsPanel.tsx | 106 +++++++++ src/components/SidePanel/SidePanel.tsx | 187 +++++++++++++++ src/components/Sidebar/FileTree.tsx | 227 ++++++++++++++++++ src/components/StatusBar/StatusBar.tsx | 163 +++++++++++++ src/components/TabsBar/TabsBar.tsx | 136 +++++++++++ src/components/TitleBar/TitleBar.tsx | 94 ++++++++ src/components/Toasts/Toasts.tsx | 72 ++++++ src/components/UI/Input.tsx | 23 ++ src/components/UI/Slider.tsx | 74 ++++++ src/components/UI/Toggle.tsx | 25 ++ src/components/Welcome/WelcomeScreen.tsx | 59 +++++ src/hooks/useCursorPosition.ts | 8 + src/hooks/useKeyboard.ts | 95 ++++++++ src/index.css | 248 +++++++++++++++++++ src/lib/languageMap.ts | 23 ++ src/lib/tauri.ts | 46 ++++ src/main.tsx | 10 + src/stores/useCommandStore.ts | 137 +++++++++++ src/stores/useContextMenuStore.ts | 31 +++ src/stores/useFilesStore.ts | 84 +++++++ src/stores/useLayoutStore.ts | 51 ++++ src/stores/useSettingsStore.ts | 34 +++ src/stores/useTabsStore.ts | 152 ++++++++++++ src/stores/useThemeStore.ts | 41 ++++ src/stores/useToastStore.ts | 31 +++ src/types/index.ts | 26 ++ src/vite-env.d.ts | 1 + 33 files changed, 3108 insertions(+) create mode 100644 src/App.tsx create mode 100644 src/components/ActivityBar/ActivityBar.tsx create mode 100644 src/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/CommandPalette/CommandPalette.tsx create mode 100644 src/components/ContextMenu/ContextMenu.tsx create mode 100644 src/components/Editor/YaceEditor.tsx create mode 100644 src/components/Settings/SettingsPanel.tsx create mode 100644 src/components/SidePanel/SidePanel.tsx create mode 100644 src/components/Sidebar/FileTree.tsx create mode 100644 src/components/StatusBar/StatusBar.tsx create mode 100644 src/components/TabsBar/TabsBar.tsx create mode 100644 src/components/TitleBar/TitleBar.tsx create mode 100644 src/components/Toasts/Toasts.tsx create mode 100644 src/components/UI/Input.tsx create mode 100644 src/components/UI/Slider.tsx create mode 100644 src/components/UI/Toggle.tsx create mode 100644 src/components/Welcome/WelcomeScreen.tsx create mode 100644 src/hooks/useCursorPosition.ts create mode 100644 src/hooks/useKeyboard.ts create mode 100644 src/index.css create mode 100644 src/lib/languageMap.ts create mode 100644 src/lib/tauri.ts create mode 100644 src/main.tsx create mode 100644 src/stores/useCommandStore.ts create mode 100644 src/stores/useContextMenuStore.ts create mode 100644 src/stores/useFilesStore.ts create mode 100644 src/stores/useLayoutStore.ts create mode 100644 src/stores/useSettingsStore.ts create mode 100644 src/stores/useTabsStore.ts create mode 100644 src/stores/useThemeStore.ts create mode 100644 src/stores/useToastStore.ts create mode 100644 src/types/index.ts create mode 100644 src/vite-env.d.ts (limited to 'src') 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 ( +
e.preventDefault()}> + + + + +
+ + {sidePanelVisible && activeView && } +
+ {activeTabId ? ( + <> + + + + + ) : ( + + )} +
+
+ +
+ ) +} 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 ( + + + + ) +} + +function SearchIcon() { + return ( + + + + + ) +} + +function ExtensionsIcon() { + return ( + + + + + + + ) +} + +function SettingsIcon() { + return ( + + + + + ) +} + +function MoonIcon() { + return ( + + + + ) +} + +function SunIcon() { + return ( + + + + + + + + + + + + ) +} + +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 = { + 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 ( + + ) +} + +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 ( +
+ {activities.map((item) => ( + + ))} + +
+ + {bottomActivities.map((item) => ( + + ))} + + +
+ ) +} 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 ( +
+ {segments.map((segment, i) => { + const isLast = i === lastIdx + + if (isLast) { + return ( + + {segment} + + ) + } + + return ( + + + + + + + ) + })} +
+ ) +} 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 ( + + + + + ) +} + +function Kbd({ keys }: { keys: string }) { + return ( + + {keys.split('+').map((k) => ( + + {k === 'Ctrl' ? '⌃' : k === 'Shift' ? '⇧' : k === 'Cmd' ? '⌘' : k} + + ))} + + ) +} + +function CommandGroup({ category, children }: { category: string; children: React.ReactNode }) { + return ( +
+
{category}
+ {children} +
+ ) +} + +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(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() + 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 ( +
+
+ +
e.stopPropagation()} + > +
+ + 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" + /> +
+ +
+ {filtered.length === 0 && ( +
+ No results found +
+ )} + + {grouped.map(([category, items]) => ( + + {items.map((cmd) => { + const idx = filtered.indexOf(cmd) + const isSelected = idx === selectedIndex + + return ( + + ) + })} + + ))} + +
+ + navigate + + + select + + + close + +
+
+
+
+ ) +} 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 ( + + + + ) + case 'redo': + return ( + + + + ) + case 'cut': + return ( + + + + ) + case 'copy': + return ( + + + + ) + case 'paste': + return ( + + + + ) + case 'selectAll': + return ( + + + + ) + case 'close': + return ( + + + + ) + case 'rename': + return ( + + + + ) + case 'trash': + return ( + + + + ) + case 'folder': + return ( + + + + ) + case 'file': + return ( + + + + ) + case 'link': + return ( + + + + ) + } +} + +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(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 ( +
+ {items.map((item) => { + if (item.separator) { + return ( +
+ ) + } + const idx = visibleItems.indexOf(item) + const isSelected = idx === selectedIndex + return ( + + ) + })} +
+ ) +} 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(null) + const viewRef = useRef(null) + const containerRef = useRef(null) + const updateTimeoutRef = useRef | 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 ( +
+
+
+ ) +} 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 ( +
+
+ {label} +
+
{children}
+
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +export default function SettingsPanel() { + const settings = useSettingsStore() + const toggleTheme = useThemeStore((s) => s.toggleTheme) + const theme = useThemeStore((s) => s.theme) + + return ( +
+
+ + + + +
+ settings.set('fontSize', v)} + /> + {settings.fontSize} +
+
+ +
+ settings.set('tabSize', v)} + /> + {settings.tabSize} +
+
+
+ +
+ + settings.set('wordWrap', v)} + /> + + + settings.set('lineNumbers', v)} + /> + + + settings.set('ruler', v)} + /> + + + settings.set('rulerColumn', parseInt(v) || 80)} + /> + +
+ +
+
+ ) +} 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 ( + + + + + + + ) +} + +function NewFolderIcon() { + return ( + + + + + + ) +} + +function OpenFolderIcon() { + return ( + + + + ) +} + +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(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 ( +
+
+ + {activeView === 'search' ? 'SEARCH' : activeView === 'extensions' ? 'EXTENSIONS' : activeView === 'settings' ? 'SETTINGS' : 'EXPLORER'} + +
+ {activeView === 'explorer' && ( + <> + + {rootPath && ( + + )} + + + )} +
+
+ +
+ {activeView === 'explorer' && ( + <> + {!rootPath && !loading && ( +
+ + + +

No folder open

+ +
+ )} + + {loading && ( +
+
+ Loading... +
+ )} + + {rootPath && !loading && ( + <> +
+ {rootPath.split(/[\\/]/).pop()} +
+ {fileTree.length > 0 ? ( + + ) : ( +
Empty directory
+ )} + + )} + + )} + + {activeView === 'search' && ( +
+ Search coming soon +
+ )} + + {activeView === 'extensions' && ( +
+ Extensions coming soon +
+ )} + + {activeView === 'settings' && ( + + )} +
+ +
+
+ ) +} 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 = { + 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 ( + + + + ) +} + +function FileTypeIcon({ name, isDir, expanded }: { name: string; isDir: boolean; expanded: boolean }) { + if (isDir) { + return ( + + {expanded ? ( + + ) : ( + + )} + + ) + } + + const ext = name.split('.').pop()?.toLowerCase() ?? '' + const color = extColors[ext] ?? '#5c5e64' + + return ( + + + + + ) +} + +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 ( +
+ {branchDepths.map((bd) => { + const l = leftBase + bd * INDENT + return ( +
+ ) + })} +
+
+
+ ) +} + +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 ( +
+ +
+ {entry.children && ( + + )} +
+
+ ) + } + + const isActive = entry.path === activeTabId + + return ( + + ) +} + +interface FileTreeProps { + entries: FileEntry[] + depth?: number + parentBranchDepths?: number[] +} + +export default function FileTree({ entries, depth = 0, parentBranchDepths = [] }: FileTreeProps) { + return ( +
+ {entries.map((entry, idx) => ( + + ))} +
+ ) +} 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 ( + + + + + + + ) +} + +function ErrorIcon() { + return ( + + + + + + ) +} + +function EncodingIcon() { + return ( + + + + + ) +} + +function Spinner() { + return ( + + + + ) +} + +function Separator() { + return ( +
+ ) +} + +function StatusItem({ + children, + title, + onClick, +}: { + children: React.ReactNode + title?: string + onClick?: () => void +}) { + return ( + + ) +} + +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 | 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 ( +
+
+ + + main + + + + + + + 0 + 0 + +
+ +
+ +
+ {isSaving && ( + + + Saving + + )} + + {statusMessage && !isSaving && ( + + + {statusMessage === 'Saved' ? '✓' : '✗'} + + + {statusMessage} + + + )} + + {activeTab && ( + + + {activeTab.language.charAt(0).toUpperCase() + activeTab.language.slice(1)} + + + )} + + + + + Spaces: {tabSize} + + + + + + + UTF-8 + + + + + {activeTab && ( + + + Ln {cursorLine}, Col {cursorCol} + + + )} +
+
+ ) +} 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 ( + + + + + ) +} + +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(null) + const activeTabRef = useRef(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 ( +
+ {showLeftFade && ( +
+ )} + {showRightFade && ( +
+ )} + +
+ {tabs.length === 0 && ( +
+ No files open +
+ )} + + {tabs.map((tab) => { + const isActive = tab.id === activeTabId + + return ( +
setActiveTab(tab.id)} + onContextMenu={(e) => handleTabContextMenu(e, tab.id)} + > + {isActive && ( + + )} + + {tab.name} + + {tab.modified && ( + + )} + + +
+ ) + })} +
+
+ ) +} 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 ( + + + + ) +} + +function MaximizeIcon() { + return ( + + + + ) +} + +function RestoreIcon() { + return ( + + + + + ) +} + +function CloseIcon() { + return ( + + + + + ) +} + +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 ( +
+
+ + + + + YACE +
+ +
+ +
+ + + + + +
+
+ ) +} 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 = { + success: 'var(--color-semantic-success)', + error: 'var(--color-semantic-error)', + info: 'var(--color-semantic-info)', + warning: 'var(--color-semantic-warning)', +} + +const typeIcons: Record = { + 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 ( +
{ if (exiting) removeToast(id) }} + > + + {typeIcons[type]} + + {message} + +
+ ) +} + +export default function Toasts() { + const toasts = useToastStore((s) => s.toasts) + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((t) => ( +
+ +
+ ))} +
+ ) +} 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 ( + 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(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 ( +
{ + 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)) } + }} + > +
+
+
+
+
+ ) +} 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 ( + + ) +} 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 ( + + {keys.map((key, i) => ( + + {i > 0 && +} + + {key} + + + ))} + + ) +} + +export default function WelcomeScreen() { + return ( +
+
+ + YACE + + + Yet Another Code Editor + +
+ +
+ {shortcuts.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ ) +} 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 = { + 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 { + return invoke('read_file', { path }) +} + +export async function writeFile(path: string, content: string): Promise { + return invoke('write_file', { path, content }) +} + +export async function openDir(path: string): Promise { + return invoke('open_dir', { path }) +} + +export async function pickDirectory(): Promise { + 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 { + return invoke('create_dir', { path }) +} + +export async function pickFile(): Promise { + 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 { + 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( + + + , +) 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((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((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 + loadFolder: (path: string) => Promise + createFile: (parentPath?: string) => Promise + createFolder: (parentPath?: string) => Promise +} + +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((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 => { + 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 => { + 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((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: (key: K, value: SettingsState[K]) => void +} + +export const useSettingsStore = create()( + 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 + pickAndOpenFile: () => Promise + createUntitledFile: () => void + closeTab: (id: string) => void + setActiveTab: (id: string) => void + updateContent: (id: string, content: string) => void + saveFile: (id: string) => Promise + setCursorPosition: (line: number, col: number) => void + clearStatusMessage: () => void +} + +function isUntitled(id: string) { + return id.startsWith('untitled://') +} + +export const useTabsStore = create((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((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((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 @@ +/// -- cgit v1.2.3