diff --git a/README.md b/README.md index 4ca9d73..381bcbb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An in-browser **C++20 IDE** delivered as a Chrome / Chromium extension. |---------|--------| | Editor | Monaco Editor (the engine behind VS Code) | | Compiler | WASM-native Clang (runs entirely in the browser, offline) | -| Terminal | xterm.js with a bash-like shell (`g++`, `./a.out`, `ls`, `mkdir`, `cat`, …) | +| Terminal | xterm.js with a bash-like shell (`g++`, `./a.out`, `ls`, `mkdir`, `touch`, `cat`, …) | | File access | File System Access API on Chromium, fallback open/save/folder flows on Firefox | | File I/O | `fstream` / `ifstream` / `ofstream` – read and write workspace files at runtime | | Standards | C++14 · C++17 · **C++20** (selectable in the toolbar) | @@ -181,6 +181,7 @@ be persisted. | `ls [-R] [dir]` | List files/folders from the opened workspace folder | | `cd [dir]` | Change the current workspace directory | | `mkdir [-p] ` | Create workspace directories (`-p` creates missing parents) | +| `touch ` | Create an empty file in the opened workspace (existing files are not overwritten) | | `cat ` | Print file contents | | `pwd` | Print working directory | | `help` | Show command list | diff --git a/manifest.json b/manifest.json index 7f049df..b3e4664 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "browser.cpp", "short_name": "browser.cpp", "description": "In-browser C++20 IDE powered by Monaco Editor and WASM Clang", - "version": "0.3.1", + "version": "0.3.2", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index 2c06f3e..2b2a06c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/package.json b/package.json index a5ba935..fee44ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.3.1", + "version": "0.3.2", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": { diff --git a/scripts/e2e-terminal-touch.test.mjs b/scripts/e2e-terminal-touch.test.mjs new file mode 100644 index 0000000..f352ebe --- /dev/null +++ b/scripts/e2e-terminal-touch.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + __executeTerminalCommandForTesting, + __handleTerminalKeyForTesting, + __setTerminalTestHarness, + setWorkspace, +} from '../src/ui/terminal.js'; + +function setupTerminalHarness(onTouch = async () => ({ ok: true })) { + const writes = []; + const touchCalls = []; + const fakeTerm = { + clear() {}, + write(text) { writes.push(text); }, + }; + + __setTerminalTestHarness({ + term: fakeTerm, + onTouch: async (request) => { + touchCalls.push(request); + return onTouch(request); + }, + }); + setWorkspace(null); + + return { writes, touchCalls }; +} + +function keyEvent(key, extra = {}) { + return { + key, + ctrlKey: false, + altKey: false, + preventDefault() {}, + ...extra, + }; +} + +test('e2e: help output lists mkdir and touch', async () => { + const ctx = setupTerminalHarness(); + + await __executeTerminalCommandForTesting('help'); + + const output = ctx.writes.join(''); + assert.ok(output.includes('mkdir [-p] ')); + assert.ok(output.includes('touch ')); +}); + +test('e2e: tab completion expands to to touch', () => { + const ctx = setupTerminalHarness(); + + __handleTerminalKeyForTesting('t', keyEvent('t')); + __handleTerminalKeyForTesting('o', keyEvent('o')); + __handleTerminalKeyForTesting('', keyEvent('Tab')); + + assert.ok(ctx.writes.join('').includes('uch ')); +}); + +test('e2e: touch creates a file relative to the workspace root', async () => { + const ctx = setupTerminalHarness(); + setWorkspace({ name: 'project', entries: [] }); + + await __executeTerminalCommandForTesting('touch notes.txt'); + + assert.deepEqual(ctx.touchCalls, [{ path: 'notes.txt' }]); +}); + +test('e2e: touch resolves paths from the current working directory', async () => { + const ctx = setupTerminalHarness(); + setWorkspace({ + name: 'project', + entries: [{ path: 'src', kind: 'directory' }], + }); + + await __executeTerminalCommandForTesting('cd src'); + await __executeTerminalCommandForTesting('touch notes.txt'); + + assert.deepEqual(ctx.touchCalls, [{ path: 'src/notes.txt' }]); +}); + +test('e2e: touch reports usage errors for missing, multiple, and option operands', async () => { + const ctx = setupTerminalHarness(); + setWorkspace({ name: 'project', entries: [] }); + + await __executeTerminalCommandForTesting('touch'); + await __executeTerminalCommandForTesting('touch one two'); + await __executeTerminalCommandForTesting('touch -p'); + + const output = ctx.writes.join(''); + assert.equal((output.match(/Usage: touch /g) || []).length, 3); + assert.deepEqual(ctx.touchCalls, []); +}); + +test('e2e: touch reports an unopened workspace', async () => { + const ctx = setupTerminalHarness(); + + await __executeTerminalCommandForTesting('touch notes.txt'); + + assert.ok(ctx.writes.join('').includes('touch: no folder opened')); + assert.deepEqual(ctx.touchCalls, []); +}); + +test('e2e: touch formats filesystem errors without overwriting', async () => { + const ctx = setupTerminalHarness(async () => ({ + ok: false, + error: 'exists', + path: 'notes.txt', + })); + setWorkspace({ name: 'project', entries: [] }); + + await __executeTerminalCommandForTesting('touch notes.txt'); + + assert.ok(ctx.writes.join('').includes("touch: cannot touch 'notes.txt': File exists")); +}); diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index 5ff9cc5..d462c74 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -309,6 +309,71 @@ test('e2e: createWorkspaceFile refuses when no workspace is open', async () => { assert.deepEqual(await fs.createWorkspaceFile('main.cpp', ''), { ok: false, error: 'no-workspace' }); }); +test('e2e: touchWorkspaceFile creates a persisted zero-byte file', async () => { + const fs = await importFreshFilesystem(); + const root = new FakeDirHandle('project'); + await fs.openFolderFromHandle(root); + + const result = await fs.touchWorkspaceFile('notes.txt'); + + assert.equal(result.ok, true); + assert.equal(result.path, 'notes.txt'); + assert.equal(await fs.readWorkspaceFile('notes.txt'), ''); + assert.ok(root.children.get('notes.txt') instanceof FakeFileHandle); + assert.equal((await root.children.get('notes.txt').getFile()).size, 0); +}); + +test('e2e: touchWorkspaceFile requires existing parent directories', async () => { + const fs = await importFreshFilesystem(); + const root = new FakeDirHandle('project'); + root.children.set('notes', new FakeDirHandle('notes')); + await fs.openFolderFromHandle(root); + + const success = await fs.touchWorkspaceFile('notes/today.txt'); + assert.equal(success.ok, true); + assert.ok(root.children.get('notes').children.get('today.txt') instanceof FakeFileHandle); + + assert.deepEqual(await fs.touchWorkspaceFile('missing/today.txt'), { + ok: false, + error: 'missing-parent', + path: 'missing', + }); +}); + +test('e2e: touchWorkspaceFile rejects existing files without truncating them', async () => { + const fs = await importFreshFilesystem(); + const root = new FakeDirHandle('project'); + const existing = new FakeFileHandle('notes.txt'); + existing.data = new TextEncoder().encode('keep this content'); + root.children.set('notes.txt', existing); + await fs.openFolderFromHandle(root); + + assert.deepEqual(await fs.touchWorkspaceFile('notes.txt'), { + ok: false, + error: 'exists', + path: 'notes.txt', + }); + assert.equal(await fs.readWorkspaceFile('notes.txt'), 'keep this content'); +}); + +test('e2e: touchWorkspaceFile reports permission failures without indexing a file', async () => { + const fs = await importFreshFilesystem(); + const root = new FakeDirHandle('project'); + root.getFileHandle = async () => { + const error = new Error('Denied'); + error.name = 'NotAllowedError'; + throw error; + }; + await fs.openFolderFromHandle(root); + + assert.deepEqual(await fs.touchWorkspaceFile('notes.txt'), { + ok: false, + error: 'permission-denied', + path: 'notes.txt', + }); + assert.equal(fs.getWorkspaceSnapshot().entries.some((entry) => entry.path === 'notes.txt'), false); +}); + test('e2e: createWorkspaceDirectory creates a root directory and refreshes the snapshot', async () => { const fs = await importFreshFilesystem(); const root = new FakeDirHandle('project'); diff --git a/src/ui/app.js b/src/ui/app.js index 3c3b851..8afcdf2 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -84,6 +84,14 @@ window.addEventListener('DOMContentLoaded', async () => { } return result; }, + onTouch: async ({ path }) => { + const result = await fsAPI.touchWorkspaceFile(path); + if (result?.ok) { + applyWorkspaceSnapshot(result.snapshot, [result.path]); + await persistenceGate.persist(); + } + return result; + }, getSource: () => editorAPI.getValue(), readWorkspaceFile: (path) => fsAPI.readWorkspaceFile(path), }); diff --git a/src/ui/filesystem.js b/src/ui/filesystem.js index 96ec8e2..18acc33 100644 --- a/src/ui/filesystem.js +++ b/src/ui/filesystem.js @@ -515,6 +515,87 @@ export async function createWorkspaceFile(inputPath, content = '') { return { ok: true, path: key, snapshot: getWorkspaceSnapshot() }; } +/** + * Create an empty file for the terminal's `touch` command. + * + * Unlike createWorkspaceFile(), this operation must persist to a writable + * folder, must not create missing parent directories, and must never truncate + * an existing file. + * + * @param {string} inputPath – workspace-relative path + * @returns {Promise<{ + * ok:true, path:string, snapshot:object + * } | { + * ok:false, error:string, path?:string + * }>} + */ +export async function touchWorkspaceFile(inputPath) { + if (!workspaceName) return { ok: false, error: 'no-workspace' }; + if (!currentDirectoryHandle) return { ok: false, error: 'not-writable' }; + + const validated = validateNewFilePath(inputPath); + if (!validated.ok) return validated; + const key = validated.path; + + if (entryExists(workspaceEntries, key)) { + return { ok: false, error: 'exists', path: key }; + } + + const segments = key.split('/').filter(Boolean); + const filename = segments.pop(); + let dirHandle = currentDirectoryHandle; + let prefix = ''; + + for (const segment of segments) { + prefix = prefix ? `${prefix}/${segment}` : segment; + try { + dirHandle = await dirHandle.getDirectoryHandle(segment, { create: false }); + } catch (err) { + if (err?.name === 'NotAllowedError') { + return { ok: false, error: 'permission-denied', path: prefix }; + } + if (err?.name === 'TypeMismatchError') { + return { ok: false, error: 'not-directory', path: prefix }; + } + return { ok: false, error: 'missing-parent', path: prefix }; + } + } + + try { + await dirHandle.getFileHandle(filename, { create: false }); + return { ok: false, error: 'exists', path: key }; + } catch (err) { + if (err?.name === 'NotAllowedError') { + return { ok: false, error: 'permission-denied', path: key }; + } + if (err?.name !== 'NotFoundError' && err?.name !== 'TypeMismatchError') { + return { ok: false, error: 'file-create-failed', path: key }; + } + if (err?.name === 'TypeMismatchError') { + return { ok: false, error: 'exists', path: key }; + } + } + + let fileHandle; + try { + fileHandle = await dirHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(new Uint8Array()); + await writable.close(); + } catch (err) { + try { await dirHandle.removeEntry(filename); } catch (_) { /* best effort cleanup */ } + return { + ok: false, + error: err?.name === 'NotAllowedError' ? 'permission-denied' : 'file-create-failed', + path: key, + }; + } + + indexWorkspaceFile(key, fileHandle); + await updateFileFingerprint(key, fileHandle); + return { ok: true, path: key, snapshot: getWorkspaceSnapshot() }; +} + /** * Create a directory in the currently opened workspace folder. * diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 2579f46..4229f49 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -11,6 +11,7 @@ * echo – print text * ls – list virtual files * mkdir [-p] – create workspace directories + * touch – create an empty workspace file * cat – print file content * pwd – print working directory * help – list available commands @@ -29,7 +30,7 @@ import { isRejectedSource, normalizeOverlayPath, } from './build-request.mjs'; -import { validateNewDirectoryPath } from './workspace-fs.mjs'; +import { validateNewDirectoryPath, validateNewFilePath } from './workspace-fs.mjs'; function moduleExports(pkg) { return Object.prototype.hasOwnProperty.call(pkg, 'default') ? pkg['default'] : pkg; @@ -57,7 +58,7 @@ const CRLF = '\r\n'; /** Maximum number of commands retained in shell history. */ const MAX_HISTORY_SIZE = 200; -const TAB_COMMANDS = ['g++ ', 'g++ main.cpp', './a.out', 'clear', 'echo ', 'ls', 'cd ', 'mkdir ', 'cat ', 'pwd', 'help']; +const TAB_COMMANDS = ['g++ ', 'g++ main.cpp', './a.out', 'clear', 'echo ', 'ls', 'cd ', 'mkdir ', 'touch ', 'cat ', 'pwd', 'help']; // ── State ───────────────────────────────────────────────────────────────────── @@ -97,6 +98,7 @@ let workspaceFiles = new Set(); let workspaceCwd = '/'; let _readWorkspaceFile = null; let _onMkdir = null; +let _onTouch = null; let initialPromptShown = false; // ── Interactive stdin (SharedArrayBuffer + Atomics) ─────────────────────────── @@ -206,9 +208,10 @@ let _getSource = null; // () => string – returns current editor source * getSource: () => string, * readWorkspaceFile?: (path:string) => Promise, * onMkdir?: (request:{path:string, parents:boolean}) => Promise, + * onTouch?: (request:{path:string}) => Promise, * }} callbacks */ -export function createTerminal(container, { onCompile, onRun, onStopRun, onRunStateChange, getSource, readWorkspaceFile, onMkdir }) { +export function createTerminal(container, { onCompile, onRun, onStopRun, onRunStateChange, getSource, readWorkspaceFile, onMkdir, onTouch }) { _onCompile = onCompile; _onRun = onRun; _onStopRun = onStopRun || null; @@ -216,6 +219,7 @@ export function createTerminal(container, { onCompile, onRun, onStopRun, onRunSt _getSource = getSource; _readWorkspaceFile = readWorkspaceFile || null; _onMkdir = onMkdir || null; + _onTouch = onTouch || null; initialPromptShown = false; busy = true; @@ -633,6 +637,9 @@ async function executeCommand(cmdLine) { case 'mkdir': await cmdMkdir(args); break; + case 'touch': + await cmdTouch(args); + break; case 'cat': await cmdCat(args); break; @@ -858,6 +865,45 @@ async function cmdMkdir(args) { writePrompt(); } +async function cmdTouch(args) { + if (!workspaceName) { + term.write(`${C.red}touch: no folder opened${C.reset}${CRLF}`); + writePrompt(); + return; + } + + const parsed = parseTouchArgs(args); + if (!parsed.ok) { + term.write(`${C.red}Usage: touch ${C.reset}${CRLF}`); + writePrompt(); + return; + } + + const rawValidated = validateNewFilePath(parsed.pathArg); + if (!rawValidated.ok) { + term.write(`${C.red}${formatTouchValidationError(rawValidated, parsed.pathArg)}${C.reset}${CRLF}`); + writePrompt(); + return; + } + + const resolvedPath = normalizePath(resolvePath(parsed.pathArg)); + const validated = validateNewFilePath(resolvedPath); + if (!validated.ok) { + term.write(`${C.red}${formatTouchValidationError(validated, parsed.pathArg)}${C.reset}${CRLF}`); + writePrompt(); + return; + } + + const result = await _onTouch?.({ path: validated.path }); + if (!result?.ok) { + term.write(`${C.red}${formatTouchFilesystemError(result, parsed.pathArg)}${C.reset}${CRLF}`); + writePrompt(); + return; + } + + writePrompt(); +} + function cmdHelp() { term.write( `${C.bold}Available commands:${C.reset}${CRLF}` + @@ -869,6 +915,7 @@ function cmdHelp() { ` ${C.green}ls [-R] [dir]${C.reset} List files/folders in the opened folder${CRLF}` + ` ${C.green}cd [dir]${C.reset} Change folder in the opened workspace${CRLF}` + ` ${C.green}mkdir [-p] ${C.reset} Create workspace directories${CRLF}` + + ` ${C.green}touch ${C.reset} Create an empty workspace file${CRLF}` + ` ${C.green}cat ${C.reset} Print file contents${CRLF}` + ` ${C.green}pwd${C.reset} Print current working directory${CRLF}` + ` ${C.green}help${C.reset} Show this message${CRLF}` + @@ -899,6 +946,7 @@ export function __setTerminalTestHarness({ getSource = () => '', readWorkspaceFile = null, onMkdir = null, + onTouch = null, } = {}) { term = terminalInstance || null; fitAddon = null; @@ -909,6 +957,7 @@ export function __setTerminalTestHarness({ _getSource = getSource; _readWorkspaceFile = readWorkspaceFile; _onMkdir = onMkdir; + _onTouch = onTouch; lastBuiltArtifactPath = artifactPath; inputBuffer = ''; history.length = 0; @@ -988,6 +1037,13 @@ function parseMkdirArgs(args) { return { ok: true, parents, pathArg: paths[0] }; } +function parseTouchArgs(args) { + if (args.length !== 1 || args[0].startsWith('-')) { + return { ok: false }; + } + return { ok: true, pathArg: args[0] }; +} + function formatMkdirValidationError(result) { if (result.error === 'invalid-name') { const chars = (result.unsupportedChars || []).map((ch) => (ch === ' ' ? "' '" : ch)); @@ -1023,6 +1079,32 @@ function formatMkdirFilesystemError(result, displayPath) { } } +function formatTouchValidationError(result, displayPath) { + if (result.error === 'empty' || result.error === 'no-filename') { + return 'Usage: touch '; + } + return `touch: cannot touch '${displayPath}': Invalid path`; +} + +function formatTouchFilesystemError(result, displayPath) { + const path = displayPath || result?.path || ''; + switch (result?.error) { + case 'exists': + return `touch: cannot touch '${path}': File exists`; + case 'missing-parent': + return `touch: cannot touch '${path}': No such file or directory`; + case 'not-directory': + return `touch: cannot touch '${path}': Not a directory`; + case 'not-writable': + case 'permission-denied': + return `touch: cannot touch '${path}': Permission denied`; + case 'no-workspace': + return 'touch: no folder opened'; + default: + return `touch: cannot touch '${path}'`; + } +} + function promptPath() { if (!workspaceName || workspaceCwd === '/') return '~'; return `~${workspaceCwd}`;