diff --git a/src/islands/playground/CodeScratchpad.tsx b/src/islands/playground/CodeScratchpad.tsx index 6effb50..3c84cc9 100644 --- a/src/islands/playground/CodeScratchpad.tsx +++ b/src/islands/playground/CodeScratchpad.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Plus, X, FolderOpen, Save, Copy, Check } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import MonacoEditor from './MonacoEditor'; @@ -19,6 +19,7 @@ const TR: Record string; newFile: string; + unsaved: string; helper: ReactNode; }> = { en: { @@ -32,10 +33,12 @@ const TR: Record `Close ${name}`, newFile: 'New file', + unsaved: 'Unsaved changes', helper: ( <> - Tabs autosave locally. Double-click a tab to rename. Move line ⌥↑/↓, add cursor ⌘⌥↑/↓, - select-next ⌘D, all occurrences ⌘⇧L, column select ⇧⌥+drag. On-device only. + Tabs autosave locally. Double-click a tab to rename. Save ⌘S, open ⌘O. + Select a word, then all occurrences ⌘⇧L (or right-click → Select All Occurrences if your browser grabs that key), + select-next ⌘D, add cursor ⌘⌥↑/↓, move line ⌥↑/↓, column select ⇧⌥+drag. On-device only. ), }, @@ -50,15 +53,46 @@ const TR: Record `Tutup ${name}`, newFile: 'File baru', + unsaved: 'Perubahan belum disimpan', helper: ( <> - Tab tersimpan otomatis secara lokal. Klik dua kali tab untuk mengganti nama. Pindah baris ⌥↑/↓, tambah kursor ⌘⌥↑/↓, - pilih-berikutnya ⌘D, semua kemunculan ⌘⇧L, pilih kolom ⇧⌥+seret. Hanya di perangkat. + Tab tersimpan otomatis secara lokal. Klik dua kali tab untuk mengganti nama. Simpan ⌘S, buka ⌘O. + Pilih sebuah kata, lalu semua kemunculan ⌘⇧L (atau klik kanan → Select All Occurrences jika browser menangkap tombol itu), + pilih-berikutnya ⌘D, tambah kursor ⌘⌥↑/↓, pindah baris ⌥↑/↓, pilih kolom ⇧⌥+seret. Hanya di perangkat. ), }, }; +// Minimal File System Access API surface (not in every TS lib.dom), so tabs can +// stay linked to a real file on disk and save back in place — like an editor. +type FsPerm = 'granted' | 'denied' | 'prompt'; +interface FsWritable { write(data: string): Promise; close(): Promise; } +interface FsFileHandle { + name: string; + getFile(): Promise; + createWritable(): Promise; + queryPermission?(d: { mode: 'read' | 'readwrite' }): Promise; + requestPermission?(d: { mode: 'read' | 'readwrite' }): Promise; +} +interface FsWindow { + showOpenFilePicker?(o?: { multiple?: boolean }): Promise; + showSaveFilePicker?(o?: { suggestedName?: string }): Promise; +} + +async function ensureReadWrite(h: FsFileHandle): Promise { + const opts = { mode: 'readwrite' as const }; + if (!h.queryPermission || !h.requestPermission) return true; + if ((await h.queryPermission(opts)) === 'granted') return true; + return (await h.requestPermission(opts)) === 'granted'; +} + +async function writeToHandle(h: FsFileHandle, content: string): Promise { + const writable = await h.createWritable(); + await writable.write(content); + await writable.close(); +} + let counter = 0; const newId = () => `f${Date.now()}-${counter++}`; @@ -72,6 +106,12 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { const [activeId, setActiveId] = useState(''); const [ready, setReady] = useState(false); const [copied, setCopied] = useState(false); + // Tabs linked to a file on disk (per tab id), and tabs with unsaved edits. + const handles = useRef>(new Map()); + const [dirty, setDirty] = useState>(new Set()); + + const markClean = (id: string) => + setDirty((d) => { if (!d.has(id)) return d; const n = new Set(d); n.delete(id); return n; }); // Restore persisted tabs on mount. useEffect(() => { @@ -92,8 +132,10 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { const active = files.find((f) => f.id === activeId) ?? null; - const updateActive = (content: string) => + const updateActive = (content: string) => { setFiles((fs) => fs.map((f) => (f.id === activeId ? { ...f, content } : f))); + setDirty((d) => (d.has(activeId) ? d : new Set(d).add(activeId))); + }; const addFile = () => { const name = prompt(t.fileNamePrompt, 'untitled.txt'); @@ -112,6 +154,8 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { }; const closeFile = (id: string) => { + handles.current.delete(id); + markClean(id); setFiles((fs) => { const next = fs.filter((f) => f.id !== id); const result = next.length ? next : [blankFile()]; @@ -121,25 +165,31 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { }; const openFromDisk = async () => { + const w = window as unknown as FsWindow; try { - const files = await fileService.openFile({ multiple: false }); - if (files.length === 0) return; - - const file = files[0]; - const content = await fileService.readFile(file); - const f: ScratchFile = { - id: newId(), - name: file.name, - language: extensionToLanguage(file.name), - content - }; - - setFiles((fs) => [...fs, f]); - setActiveId(f.id); - } catch (e) { - if ((e as Error).message !== 'No files selected' && (e as Error).name !== 'AbortError') { - alert(t.couldNotOpen); + if (w.showOpenFilePicker) { + // Keep the handle so edits can be saved straight back to this file. + const [handle] = await w.showOpenFilePicker({ multiple: false }); + if (!handle) return; + const file = await handle.getFile(); + const content = await file.text(); + const id = newId(); + handles.current.set(id, handle); + setFiles((fs) => [...fs, { id, name: file.name, language: extensionToLanguage(file.name), content }]); + setActiveId(id); + } else { + // Fallback (Firefox/Safari): read-only open, no in-place save. + const picked = await fileService.openFile({ multiple: false }); + if (picked.length === 0) return; + const file = picked[0]; + const content = await fileService.readFile(file); + const id = newId(); + setFiles((fs) => [...fs, { id, name: file.name, language: extensionToLanguage(file.name), content }]); + setActiveId(id); } + } catch (e) { + const err = e as Error; + if (err.name !== 'AbortError' && err.message !== 'No files selected') alert(t.couldNotOpen); } }; @@ -156,13 +206,35 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { const saveActive = async () => { if (!active) return; + const fileId = active.id; + const w = window as unknown as FsWindow; try { - await fileService.saveFile(active.content, { - suggestedName: active.name, - }); + const linked = handles.current.get(fileId); + if (linked) { + // Already tied to a file on disk → write straight back, no dialog. + if (!(await ensureReadWrite(linked))) return; + await writeToHandle(linked, active.content); + markClean(fileId); + return; + } + if (w.showSaveFilePicker) { + const handle = await w.showSaveFilePicker({ suggestedName: active.name }); + await writeToHandle(handle, active.content); + handles.current.set(fileId, handle); + // Adopt the chosen file name (and its language) for this tab. + if (handle.name !== active.name) { + const nm = handle.name; + setFiles((fs) => fs.map((f) => (f.id === fileId ? { ...f, name: nm, language: extensionToLanguage(nm) } : f))); + } + markClean(fileId); + } else { + // Fallback: plain download (can't link or rename in place). + await fileService.saveFile(active.content, { suggestedName: active.name }); + markClean(fileId); + } } catch (e) { - // User cancelled or error - no action needed - console.warn('Save cancelled or failed:', e); + const err = e as Error; + if (err.name !== 'AbortError') console.warn('Save cancelled or failed:', err); } }; @@ -178,7 +250,9 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { onDoubleClick={() => renameFile(f.id)} className={`flex items-center gap-1 border-2 px-2 py-1 text-sm ${f.id === activeId ? 'border-border bg-accent text-accent-foreground' : 'border-border bg-muted'}`} > - + ))} @@ -204,6 +278,8 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) { value={active.content} language={active.language} onChange={updateActive} + onSave={saveActive} + onOpen={openFromDisk} /> )} diff --git a/src/islands/playground/MonacoEditor.tsx b/src/islands/playground/MonacoEditor.tsx index 156ba97..90a38f1 100644 --- a/src/islands/playground/MonacoEditor.tsx +++ b/src/islands/playground/MonacoEditor.tsx @@ -1,24 +1,33 @@ import { useEffect, useRef } from 'react'; import type * as Monaco from 'monaco-editor'; +import { editorKeybindings } from '@/tools/playground/editor-actions.lib'; interface MonacoEditorProps { value: string; language: string; onChange?: (value: string) => void; onMount?: (editor: Monaco.editor.IStandaloneCodeEditor) => void; + /** Cmd/Ctrl+S inside the editor (falls back to VS Code-style save). */ + onSave?: () => void; + /** Cmd/Ctrl+O inside the editor. */ + onOpen?: () => void; readOnly?: boolean; options?: Monaco.editor.IStandaloneEditorConstructionOptions; height?: string; } export default function MonacoEditor({ - value, language, onChange, onMount, readOnly, options, height = '60vh', + value, language, onChange, onMount, onSave, onOpen, readOnly, options, height = '60vh', }: MonacoEditorProps) { const hostRef = useRef(null); const editorRef = useRef(null); const monacoRef = useRef(null); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; // Latest value/language, so the async editor creation uses whatever the props // are by the time Monaco finishes loading (props can change during the await). const valueRef = useRef(value); @@ -50,6 +59,27 @@ export default function MonacoEditor({ }); editorRef.current = editor; sub = editor.onDidChangeModelContent(() => onChangeRef.current?.(editor.getValue())); + + // Re-assert the VS Code multi-cursor binding AND surface it in the + // right-click menu, so "select all occurrences" stays reachable even when + // the browser or an extension swallows Cmd/Ctrl+Shift+L. Also wire the + // VS Code-style Save / Open shortcuts to the host's file handlers. + const kb = editorKeybindings(monaco); + editor.addAction({ + id: 'gwt.selectAllOccurrences', + label: 'Select All Occurrences', + keybindings: [kb.selectAllOccurrences], + contextMenuGroupId: '9_cutcopypaste', + contextMenuOrder: 1.5, + run: (ed) => { void ed.getAction('editor.action.selectHighlights')?.run(); }, + }); + if (onSaveRef.current) { + editor.addCommand(kb.save, () => onSaveRef.current?.()); + } + if (onOpenRef.current) { + editor.addCommand(kb.open, () => onOpenRef.current?.()); + } + onMount?.(editor); observer = new MutationObserver(() => diff --git a/src/tools/playground/editor-actions.lib.test.ts b/src/tools/playground/editor-actions.lib.test.ts new file mode 100644 index 0000000..ee01027 --- /dev/null +++ b/src/tools/playground/editor-actions.lib.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { editorKeybindings } from './editor-actions.lib'; + +// The real Monaco enum values (monaco-editor 0.52): KeyMod.CtrlCmd=2048, +// Shift=1024; KeyCode.KeyL=42, KeyS=49, KeyO=45. Locking the exact chords guards +// against an accidental combo change (e.g. KeyL → KeyK). +const KEYS = { + KeyMod: { CtrlCmd: 2048, Shift: 1024, Alt: 512, WinCtrl: 256 }, + KeyCode: { KeyL: 42, KeyS: 49, KeyO: 45 }, +}; + +describe('editorKeybindings', () => { + const kb = editorKeybindings(KEYS); + + it('binds Select All Occurrences to Cmd/Ctrl+Shift+L', () => { + expect(kb.selectAllOccurrences).toBe(2048 | 1024 | 42); // 3114 + }); + it('binds Save to Cmd/Ctrl+S', () => { + expect(kb.save).toBe(2048 | 49); // 2097 + }); + it('binds Open to Cmd/Ctrl+O', () => { + expect(kb.open).toBe(2048 | 45); // 2093 + }); +}); diff --git a/src/tools/playground/editor-actions.lib.ts b/src/tools/playground/editor-actions.lib.ts new file mode 100644 index 0000000..b219b92 --- /dev/null +++ b/src/tools/playground/editor-actions.lib.ts @@ -0,0 +1,31 @@ +/** + * VS Code-style keybindings for the Monaco editor, expressed as the numeric + * chords Monaco's addAction/addCommand expect. Kept pure (the KeyMod/KeyCode + * constants are injected) so the exact combos are unit-tested and can't silently + * drift. Wired up in MonacoEditor. + */ + +// Structural subset of monaco's KeyMod/KeyCode enums — narrow to the members we +// use so the real monaco namespace (whose enums have no string index signature) +// is assignable here. +export interface MonacoKeys { + KeyMod: { CtrlCmd: number; Shift: number }; + KeyCode: { KeyL: number; KeyS: number; KeyO: number }; +} + +export interface EditorKeybindings { + /** Cmd/Ctrl+Shift+L — select all occurrences of the current selection. */ + selectAllOccurrences: number; + /** Cmd/Ctrl+S — save the active file. */ + save: number; + /** Cmd/Ctrl+O — open a file from disk. */ + open: number; +} + +export function editorKeybindings({ KeyMod, KeyCode }: MonacoKeys): EditorKeybindings { + return { + selectAllOccurrences: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL, + save: KeyMod.CtrlCmd | KeyCode.KeyS, + open: KeyMod.CtrlCmd | KeyCode.KeyO, + }; +}