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/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 +++++ 16 files changed, 2044 insertions(+) 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 (limited to 'src/components') 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} +
+ ))} +
+
+ ) +} -- cgit v1.2.3