diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..20aadf6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# Pre-commit gate: formatting, linting, tests must all pass. +# Enabled via `core.hooksPath` (set by the `prepare` npm script). +set -e + +echo "pre-commit › prettier --check" +npm run format:check + +echo "pre-commit › eslint" +npm run lint + +echo "pre-commit › vitest" +npm test diff --git a/.opencode/skills/textile-design/components.md b/.opencode/skills/textile-design/components.md index 6772baf..737d3af 100644 --- a/.opencode/skills/textile-design/components.md +++ b/.opencode/skills/textile-design/components.md @@ -77,10 +77,12 @@ Use the **native `title` attribute**. No custom CSS, no custom markup. ``` The browser renders the OS-native tooltip: + - macOS: dark translucent bubble below the element - Windows/Linux: follows OS theme **Rules:** + - Every interactive control that needs extra context gets a `title` - Icon-only buttons **must** also have `aria-label` (screen readers don't read `title` reliably) - Never build a custom tooltip with CSS/JS when `title` is sufficient diff --git a/package.json b/package.json index 0147c4a..9d9c1e1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepare": "git config core.hooksPath .githooks || true" }, "keywords": [], "author": "ethan", diff --git a/src/actions/CommandPalette.tsx b/src/actions/CommandPalette.tsx index 74e345a..2a9c19c 100644 --- a/src/actions/CommandPalette.tsx +++ b/src/actions/CommandPalette.tsx @@ -79,7 +79,9 @@ export function CommandPalette({ // Scroll selected item into view useEffect(() => { if (listRef.current && filteredActions.length > 0) { - const selectedEl = listRef.current.querySelector('[aria-selected="true"]'); + const selectedEl = listRef.current.querySelector( + '[aria-selected="true"]', + ); if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { selectedEl.scrollIntoView({ block: 'nearest' }); } @@ -91,16 +93,14 @@ export function CommandPalette({ switch (e.key) { case 'ArrowDown': e.preventDefault(); - setSelectedIndex((prev) => - (prev + 1) % Math.max(filteredActions.length, 1), + setSelectedIndex( + (prev) => (prev + 1) % Math.max(filteredActions.length, 1), ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => - prev <= 0 - ? Math.max(filteredActions.length - 1, 0) - : prev - 1, + prev <= 0 ? Math.max(filteredActions.length - 1, 0) : prev - 1, ); break; case 'Enter': @@ -151,7 +151,7 @@ export function CommandPalette({ setQuery(e.target.value)} @@ -165,10 +165,7 @@ export function CommandPalette({ {/* Results */} -
+
{filteredActions.length === 0 ? (
{query.trim() @@ -181,7 +178,8 @@ export function CommandPalette({
{filteredActions.map((action, index) => { const isSelected = index === selectedIndex; - const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); + const effectiveHotkeys = + hotkeyStore.getEffectiveHotkeys(action); const hotkeyHint = effectiveHotkeys[0] ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) : undefined; @@ -192,7 +190,7 @@ export function CommandPalette({ id={`command-palette-item-${index}`} role="option" aria-selected={isSelected} - className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm select-none transition-none ${ + className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-none select-none ${ isSelected ? 'bg-accent/10 text-foreground' : 'text-foreground/70 hover:bg-border/35' diff --git a/src/actions/hotkey-manager.ts b/src/actions/hotkey-manager.ts index 56275b8..ad2be7b 100644 --- a/src/actions/hotkey-manager.ts +++ b/src/actions/hotkey-manager.ts @@ -211,7 +211,9 @@ export class HotkeyManager { if (target && target.tagName) { const tagName = target.tagName.toLowerCase(); const isInput = - tagName === 'input' || tagName === 'textarea' || target.isContentEditable; + tagName === 'input' || + tagName === 'textarea' || + target.isContentEditable; if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) { return; } diff --git a/src/actions/hotkey-utils.ts b/src/actions/hotkey-utils.ts index ca45c36..a5a4115 100644 --- a/src/actions/hotkey-utils.ts +++ b/src/actions/hotkey-utils.ts @@ -2,7 +2,13 @@ const MODIFIER_ORDER: Modifier[] = ['Ctrl', 'Meta', 'Alt', 'Shift']; /** Known modifier names for validation. */ -const KNOWN_MODIFIERS = new Set(['Ctrl', 'Meta', 'Alt', 'Shift', 'Mod']); +const KNOWN_MODIFIERS = new Set([ + 'Ctrl', + 'Meta', + 'Alt', + 'Shift', + 'Mod', +]); import type { Modifier, Hotkey } from './types'; diff --git a/src/actions/hotkeyStore.ts b/src/actions/hotkeyStore.ts index 8ec6df8..8f403a3 100644 --- a/src/actions/hotkeyStore.ts +++ b/src/actions/hotkeyStore.ts @@ -99,7 +99,10 @@ export class HotkeyStore { * Find hotkey conflicts among all registered actions using effective hotkeys. * Returns a map of normalized hotkey string -> array of conflicting action IDs. */ - getConflicts(registry: ActionRegistry, isMac: boolean): Map { + getConflicts( + registry: ActionRegistry, + isMac: boolean, + ): Map { const bindings = new Map(); for (const action of registry.getAll()) { diff --git a/src/actions/registry.ts b/src/actions/registry.ts index 4a8de42..6f57e5b 100644 --- a/src/actions/registry.ts +++ b/src/actions/registry.ts @@ -111,7 +111,10 @@ export class ActionRegistry { try { const result = action.execute(); - if (result && typeof (result as PromiseLike).then === 'function') { + if ( + result && + typeof (result as PromiseLike).then === 'function' + ) { await result; } return true; diff --git a/src/actions/services.ts b/src/actions/services.ts index 0ea20a6..692aa6c 100644 --- a/src/actions/services.ts +++ b/src/actions/services.ts @@ -14,13 +14,9 @@ export function createActionServices(isMac: boolean) { const registry = new ActionRegistry(); const hotkeyStore = new HotkeyStore(); - const hotkeyManager = new HotkeyManager( - registry, - isMac, - (actionId) => { - void registry.execute(actionId); - }, - ); + const hotkeyManager = new HotkeyManager(registry, isMac, (actionId) => { + void registry.execute(actionId); + }); return { registry, hotkeyManager, hotkeyStore }; } diff --git a/src/actions/sets/sidebarActionSet.ts b/src/actions/sets/sidebarActionSet.ts index 8a251c1..7811aac 100644 --- a/src/actions/sets/sidebarActionSet.ts +++ b/src/actions/sets/sidebarActionSet.ts @@ -7,7 +7,9 @@ export class SidebarActionSet implements ActionSet { readonly id = 'sidebar'; constructor( - private setSidebarOpen: (open: boolean | ((prev: boolean) => boolean)) => void, + private setSidebarOpen: ( + open: boolean | ((prev: boolean) => boolean), + ) => void, ) {} getActions(): ActionDefinition[] { diff --git a/src/actions/sets/themeActionSet.ts b/src/actions/sets/themeActionSet.ts index 16455c0..df27245 100644 --- a/src/actions/sets/themeActionSet.ts +++ b/src/actions/sets/themeActionSet.ts @@ -7,7 +7,9 @@ export class ThemeActionSet implements ActionSet { readonly id = 'theme'; constructor( - private setTheme: (theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark')) => void, + private setTheme: ( + theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark'), + ) => void, ) {} getActions(): ActionDefinition[] { diff --git a/src/actions/sets/tilingActionSet.ts b/src/actions/sets/tilingActionSet.ts index 2208805..ca739cb 100644 --- a/src/actions/sets/tilingActionSet.ts +++ b/src/actions/sets/tilingActionSet.ts @@ -18,9 +18,7 @@ export interface TilingHandle { export class TilingActionSet implements ActionSet { readonly id = 'tiling'; - constructor( - private handleRef: MutableRefObject, - ) {} + constructor(private handleRef: MutableRefObject) {} getActions(): ActionDefinition[] { return [ diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 28b34ef..3f670f8 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,12 +1,13 @@ -import { - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { FileSystemProvider } from '../filesystem/types'; -import { NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon, CloudDownloadIcon } from '../icons'; +import { + NewDocumentIcon, + NewDirectoryIcon, + StackedChevronsIcon, + PlusIcon, + CloudDownloadIcon, +} from '../icons'; import type { RemoteVault } from '../vault/registry'; import type { OpenDocRequest } from '../workspaces/workspace'; import { FileSystemTree } from './VaultTree'; @@ -52,7 +53,12 @@ type ProviderSectionProps = { onInlineCreateChange: (name: string) => void; onInlineCreateSubmit: (name: string) => void; onInlineCreateCancel: () => void; - onContextMenuAction: (action: string, nodeId: string, nodeName: string, nodeType: 'file' | 'folder') => void; + onContextMenuAction: ( + action: string, + nodeId: string, + nodeName: string, + nodeType: 'file' | 'folder', + ) => void; onTreeRootRightClick: (action: string) => void; }; @@ -89,7 +95,9 @@ function ProviderSection({ useEffect(() => { if (!provider.subscribeReady) return; return provider.subscribeReady(() => { - void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(provider.id), + }); }); }, [provider, queryClient]); @@ -108,10 +116,15 @@ function ProviderSection({ ); const handleContextMenu = useCallback( - async (node: { id: string; name: string; type: 'file' | 'folder' }, items: ContextMenuItem[]) => { + async ( + node: { id: string; name: string; type: 'file' | 'folder' }, + items: ContextMenuItem[], + ) => { const api = window.textile?.contextMenu; if (!api) return; - const action = await api.show(items.map((item) => ({ id: item.id, label: item.label }))); + const action = await api.show( + items.map((item) => ({ id: item.id, label: item.label })), + ); if (action) { onContextMenuAction(action, node.id, node.name, node.type); } @@ -136,11 +149,11 @@ function ProviderSection({ ); return ( -
+

{provider.displayName}

-
+
{!provider.isReady() ? (

{provider.idleHint} @@ -155,27 +168,32 @@ function ProviderSection({

) : ( <> -
- -
+
+ +
{treeQuery.data?.length === 0 && !inlineCreate && (

No files. @@ -222,15 +240,15 @@ function ProviderSelector({ {open && ( -

+
{providers.map((provider) => ( @@ -289,7 +307,9 @@ export function AppSidebar({ const queryClient = useQueryClient(); const [selection, setSelection] = useState(null); const [createError, setCreateError] = useState(null); - const [inlineCreate, setInlineCreate] = useState(null); + const [inlineCreate, setInlineCreate] = useState( + null, + ); const [renameNodeId, setRenameNodeId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [renameExt, setRenameExt] = useState(''); @@ -312,23 +332,42 @@ export function AppSidebar({ }, [selectedProviderId]); const handleContextMenuAction = useCallback( - async (action: string, nodeId: string, nodeName: string, _nodeType: 'file' | 'folder') => { + async ( + action: string, + nodeId: string, + nodeName: string, + _nodeType: 'file' | 'folder', + ) => { if (!selectedProvider) return; switch (action) { case 'new-note': { - setInlineCreate({ parentId: nodeId, type: 'file', name: '', error: null }); + setInlineCreate({ + parentId: nodeId, + type: 'file', + name: '', + error: null, + }); break; } case 'new-folder': { - setInlineCreate({ parentId: nodeId, type: 'folder', name: '', error: null }); + setInlineCreate({ + parentId: nodeId, + type: 'folder', + name: '', + error: null, + }); break; } case 'copy': { try { await selectedProvider.copyFile(nodeId); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(selectedProvider.id), + }); } catch (e: unknown) { - setCreateError(e instanceof Error ? e.message : 'Could not copy file'); + setCreateError( + e instanceof Error ? e.message : 'Could not copy file', + ); } break; } @@ -341,7 +380,9 @@ export function AppSidebar({ } case 'rename': { setRenameNodeId(nodeId); - const ext = nodeName.includes('.') ? nodeName.slice(nodeName.lastIndexOf('.')) : ''; + const ext = nodeName.includes('.') + ? nodeName.slice(nodeName.lastIndexOf('.')) + : ''; const base = ext ? nodeName.slice(0, -ext.length) : nodeName; setRenameValue(base); setRenameExt(ext); @@ -352,9 +393,13 @@ export function AppSidebar({ if (window.confirm(`Delete "${nodeName}"? This cannot be undone.`)) { try { await selectedProvider.deleteFile(nodeId); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(selectedProvider.id), + }); } catch (e: unknown) { - setCreateError(e instanceof Error ? e.message : 'Could not delete'); + setCreateError( + e instanceof Error ? e.message : 'Could not delete', + ); } } break; @@ -372,7 +417,10 @@ export function AppSidebar({ if (inlineCreate.type === 'file') { try { - const entry = await selectedProvider.createFile(inlineCreate.parentId, trimmed || undefined); + const entry = await selectedProvider.createFile( + inlineCreate.parentId, + trimmed || undefined, + ); setInlineCreate(null); setSelection({ providerId: selectedProvider.id, entryId: entry.id }); onOpenEntry?.({ @@ -380,17 +428,31 @@ export function AppSidebar({ entryId: entry.id, title: entry.name, }); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(selectedProvider.id), + }); } catch (e: unknown) { - setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create file' }); + setInlineCreate({ + ...inlineCreate, + error: e instanceof Error ? e.message : 'Could not create file', + }); } } else { try { - await selectedProvider.createDirectory(trimmed || 'Untitled', inlineCreate.parentId); + await selectedProvider.createDirectory( + trimmed || 'Untitled', + inlineCreate.parentId, + ); setInlineCreate(null); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(selectedProvider.id), + }); } catch (e: unknown) { - setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create directory' }); + setInlineCreate({ + ...inlineCreate, + error: + e instanceof Error ? e.message : 'Could not create directory', + }); } } }, @@ -402,7 +464,7 @@ export function AppSidebar({ }, []); const handleInlineCreateChange = useCallback((name: string) => { - setInlineCreate((prev) => prev ? { ...prev, name, error: null } : null); + setInlineCreate((prev) => (prev ? { ...prev, name, error: null } : null)); }, []); const handleRenameSubmit = useCallback( @@ -423,7 +485,9 @@ export function AppSidebar({ setRenameValue(''); setRenameExt(''); setRenameError(null); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + void queryClient.invalidateQueries({ + queryKey: treeQueryKey(selectedProvider.id), + }); } catch (e: unknown) { setRenameError(e instanceof Error ? e.message : 'Could not rename'); } @@ -438,19 +502,25 @@ export function AppSidebar({ setRenameError(null); }, []); - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - resizeStartX.current = e.clientX; - resizeStartWidth.current = sidebarWidth; - setIsResizing(true); - }, [sidebarWidth]); + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + resizeStartX.current = e.clientX; + resizeStartWidth.current = sidebarWidth; + setIsResizing(true); + }, + [sidebarWidth], + ); useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: MouseEvent) => { const maxWidth = window.innerWidth * 0.5; - const newWidth = resizeStartWidth.current + (e.clientX - resizeStartX.current); - setSidebarWidth(Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth))); + const newWidth = + resizeStartWidth.current + (e.clientX - resizeStartX.current); + setSidebarWidth( + Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)), + ); }; const handleMouseUp = () => { setIsResizing(false); @@ -477,7 +547,7 @@ export function AppSidebar({ return (