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