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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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] <dir>` | Create workspace directories (`-p` creates missing parents) |
| `touch <file>` | Create an empty file in the opened workspace (existing files are not overwritten) |
| `cat <file>` | Print file contents |
| `pwd` | Print working directory |
| `help` | Show command list |
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
116 changes: 116 additions & 0 deletions scripts/e2e-terminal-touch.test.mjs
Original file line number Diff line number Diff line change
@@ -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] <dir>'));
assert.ok(output.includes('touch <file>'));
});

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 <file>/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"));
});
65 changes: 65 additions & 0 deletions scripts/e2e-workspace-file-tracking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 8 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
81 changes: 81 additions & 0 deletions src/ui/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading