Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 105 additions & 29 deletions src/islands/playground/CodeScratchpad.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +19,7 @@ const TR: Record<Lang, {
loading: string;
closeFile: (name: string) => string;
newFile: string;
unsaved: string;
helper: ReactNode;
}> = {
en: {
Expand All @@ -32,10 +33,12 @@ const TR: Record<Lang, {
loading: 'Loading…',
closeFile: (name) => `Close ${name}`,
newFile: 'New file',
unsaved: 'Unsaved changes',
helper: (
<>
Tabs autosave locally. Double-click a tab to rename. Move line <kbd>⌥↑/↓</kbd>, add cursor <kbd>⌘⌥↑/↓</kbd>,
select-next <kbd>⌘D</kbd>, all occurrences <kbd>⌘⇧L</kbd>, column select <kbd>⇧⌥</kbd>+drag. On-device only.
Tabs autosave locally. Double-click a tab to rename. Save <kbd>⌘S</kbd>, open <kbd>⌘O</kbd>.
Select a word, then all occurrences <kbd>⌘⇧L</kbd> (or right-click → <em>Select All Occurrences</em> if your browser grabs that key),
select-next <kbd>⌘D</kbd>, add cursor <kbd>⌘⌥↑/↓</kbd>, move line <kbd>⌥↑/↓</kbd>, column select <kbd>⇧⌥</kbd>+drag. On-device only.
</>
),
},
Expand All @@ -50,15 +53,46 @@ const TR: Record<Lang, {
loading: 'Memuat…',
closeFile: (name) => `Tutup ${name}`,
newFile: 'File baru',
unsaved: 'Perubahan belum disimpan',
helper: (
<>
Tab tersimpan otomatis secara lokal. Klik dua kali tab untuk mengganti nama. Pindah baris <kbd>⌥↑/↓</kbd>, tambah kursor <kbd>⌘⌥↑/↓</kbd>,
pilih-berikutnya <kbd>⌘D</kbd>, semua kemunculan <kbd>⌘⇧L</kbd>, pilih kolom <kbd>⇧⌥</kbd>+seret. Hanya di perangkat.
Tab tersimpan otomatis secara lokal. Klik dua kali tab untuk mengganti nama. Simpan <kbd>⌘S</kbd>, buka <kbd>⌘O</kbd>.
Pilih sebuah kata, lalu semua kemunculan <kbd>⌘⇧L</kbd> (atau klik kanan → <em>Select All Occurrences</em> jika browser menangkap tombol itu),
pilih-berikutnya <kbd>⌘D</kbd>, tambah kursor <kbd>⌘⌥↑/↓</kbd>, pindah baris <kbd>⌥↑/↓</kbd>, pilih kolom <kbd>⇧⌥</kbd>+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<void>; close(): Promise<void>; }
interface FsFileHandle {
name: string;
getFile(): Promise<File>;
createWritable(): Promise<FsWritable>;
queryPermission?(d: { mode: 'read' | 'readwrite' }): Promise<FsPerm>;
requestPermission?(d: { mode: 'read' | 'readwrite' }): Promise<FsPerm>;
}
interface FsWindow {
showOpenFilePicker?(o?: { multiple?: boolean }): Promise<FsFileHandle[]>;
showSaveFilePicker?(o?: { suggestedName?: string }): Promise<FsFileHandle>;
}

async function ensureReadWrite(h: FsFileHandle): Promise<boolean> {
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<void> {
const writable = await h.createWritable();
await writable.write(content);
await writable.close();
}

let counter = 0;
const newId = () => `f${Date.now()}-${counter++}`;

Expand All @@ -72,6 +106,12 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) {
const [activeId, setActiveId] = useState<string>('');
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<Map<string, FsFileHandle>>(new Map());
const [dirty, setDirty] = useState<Set<string>>(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(() => {
Expand All @@ -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');
Expand All @@ -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()];
Expand All @@ -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);
}
};

Expand All @@ -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);
}
};

Expand All @@ -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'}`}
>
<button onClick={() => setActiveId(f.id)} className="font-bold">{f.name}</button>
<button onClick={() => setActiveId(f.id)} className="font-bold" title={dirty.has(f.id) ? t.unsaved : undefined}>
{dirty.has(f.id) && <span aria-hidden className="mr-0.5">*</span>}{f.name}
</button>
<button onClick={() => closeFile(f.id)} aria-label={t.closeFile(f.name)}><X className="h-3.5 w-3.5" /></button>
</div>
))}
Expand All @@ -204,6 +278,8 @@ export default function CodeScratchpad({ lang = 'en' }: { lang?: Lang }) {
value={active.content}
language={active.language}
onChange={updateActive}
onSave={saveActive}
onOpen={openFromDisk}
/>
)}
</div>
Expand Down
32 changes: 31 additions & 1 deletion src/islands/playground/MonacoEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof Monaco | null>(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);
Expand Down Expand Up @@ -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(() =>
Expand Down
24 changes: 24 additions & 0 deletions src/tools/playground/editor-actions.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
31 changes: 31 additions & 0 deletions src/tools/playground/editor-actions.lib.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading