Build on minwebide's project-app shell
projects.ts, landing.ts/css, githubOpen.ts, and the routing/IDE glue move
into minwebide; the app is now its ProjectAppConfig plus the numbl workbench
assembly. Storage keys are unchanged, so existing projects survive.
6 changed files+28−635
src/githubOpen.tsdeleted+0−76View file
@@ -1,76 +0,0 @@
1-import { applyThemeToElement, attachGitHubWorkspace, createIndexedDBFileSystem, parseGitHubSpec, type WorkbenchTheme } from 'minwebide';
2-import { openNumblWorkbench, openStartingFile } from './ide';
3-import './landing.css';
4-
5-// The #/github/<spec> route: a GitHub repository as a workspace of its own.
6-// <spec> is anything parseGitHubSpec accepts — owner/repo, owner/repo@ref, or
7-// a URL-encoded github.com URL. The URL is the identity: nothing is added to
8-// the project registry. The first visit imports into a per-repo IndexedDB
9-// database; later visits reopen that local copy, edits included, with the
10-// Source Control view tracking changes against the imported commit (and
11-// offering "Reload from GitHub" to start fresh).
12-
13-/** The per-repo workspace database backing a #/github route. */
14-export function githubWorkspaceDbName(spec: { owner: string; repo: string; ref?: string; dir?: string }): string {
15- return `numbl-web-ide-gh-${spec.owner}-${spec.repo}${spec.ref ? `-${spec.ref}` : ''}${spec.dir ? `-${spec.dir}` : ''}`
16- .toLowerCase().replace(/[^a-z0-9._-]/g, '-');
17-}
18-
19-/** Handles a #/github/<spec> route. Returns a disposable view (the IDE, or an error screen). */
20-export async function openGitHubRoute(container: HTMLElement, specText: string, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
21- let fs: Awaited<ReturnType<typeof createIndexedDBFileSystem>> | undefined;
22- let ide: Awaited<ReturnType<typeof openNumblWorkbench>> | undefined;
23- try {
24- const spec = parseGitHubSpec(specText);
25- const name = `${spec.owner}/${spec.repo}`;
26- document.title = `${name} — numbl web IDE`;
27-
28- fs = await createIndexedDBFileSystem({ dbName: githubWorkspaceDbName(spec) });
29- ide = await openNumblWorkbench(container, fs, name, theme);
30- ide.workbench.statusBar.removeItem('branding');
31- ide.workbench.statusBar.setItem('project', 'left', 'Projects', {
32- icon: 'arrow-left',
33- title: 'Back to projects',
34- onClick: () => { location.hash = '#/'; },
35- });
36-
37- // imports on first visit (status bar progress + GitHub output channel);
38- // the README is left to openStartingFile, which prefers numbl entry points
39- const view = await attachGitHubWorkspace(ide.workbench, fs, spec, { autoOpenReadme: false, appName: 'numbl web IDE' });
40- await openStartingFile(fs, ide.workbench);
41-
42- return {
43- dispose() {
44- view.dispose();
45- ide!.dispose();
46- fs!.dispose();
47- },
48- };
49- } catch (error) {
50- ide?.dispose();
51- fs?.dispose();
52- container.textContent = '';
53- const message = error instanceof Error ? error.message : String(error);
54- return renderErrorScreen(container, theme, `Could not open repository: ${message}`);
55- }
56-}
57-
58-function renderErrorScreen(container: HTMLElement, theme: WorkbenchTheme, text: string): { dispose(): void } {
59- const root = document.createElement('div');
60- root.className = 'landing';
61- applyThemeToElement(theme, root);
62- const inner = document.createElement('div');
63- inner.className = 'landing-inner';
64- const message = document.createElement('p');
65- message.className = 'landing-subtitle';
66- message.textContent = text;
67- inner.appendChild(message);
68- const back = document.createElement('a');
69- back.className = 'landing-link';
70- back.href = '#/';
71- back.textContent = 'Back to projects';
72- inner.appendChild(back);
73- root.appendChild(inner);
74- container.appendChild(root);
75- return { dispose: () => root.remove() };
76-}
src/ide.tsmodified+5−70View file
@@ -1,20 +1,13 @@
1-import { attachGitHubSourceControl, createIndexedDBFileSystem, createWorkbench, transplantGitHubWorkspace, type Workbench, type WorkbenchTheme, type WorkspaceFileSystem } from 'minwebide';
2-import { githubWorkspaceDbName } from './githubOpen';
1+import { createWorkbench, type AppWorkbench, type WorkbenchTheme, type WorkspaceFileSystem } from 'minwebide';
32 import { createMipSystem } from './numbl/mipSystem';
43 import { createNumblRunner } from './numbl/runner';
5-import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
6-
7-export interface NumblWorkbench {
8- readonly workbench: Workbench;
9- dispose(): void;
10-}
114
125 /**
13- * Assembles the numbl workbench (mip system + runner) on a file system.
14- * Shared by project IDEs and GitHub repo IDEs; does not own `fs` — the
15- * caller disposes it.
6+ * Assembles the numbl workbench (mip system + runner) on a file system. Used
7+ * by the project-app shell for both project IDEs and GitHub repo IDEs; does
8+ * not own `fs` — the caller disposes it.
169 */
17-export async function openNumblWorkbench(container: HTMLElement, fs: WorkspaceFileSystem, workspaceName: string, theme: WorkbenchTheme): Promise<NumblWorkbench> {
10+export async function openNumblWorkbench(container: HTMLElement, fs: WorkspaceFileSystem, workspaceName: string, theme: WorkbenchTheme): Promise<AppWorkbench> {
1811 const mip = await createMipSystem();
1912 // fetch/refresh mip core in the background; runs await it (and say so
2013 // in the output channel if they actually have to wait)
@@ -38,61 +31,3 @@ export async function openNumblWorkbench(container: HTMLElement, fs: WorkspaceFi
3831 },
3932 };
4033 }
41-
42-/** Opens the most useful starting file, if any. */
43-export async function openStartingFile(fs: WorkspaceFileSystem, workbench: Workbench): Promise<void> {
44- for (const path of ['/waves.m', '/main.m', '/README.md']) {
45- const uri = fs.root.with({ path });
46- if (await fs.fileService.exists(uri)) {
47- await workbench.openFile(uri);
48- return;
49- }
50- }
51-}
52-
53-/** Opens the IDE for a project. Returns a disposable view. */
54-export async function openIde(container: HTMLElement, project: ProjectInfo, theme: WorkbenchTheme): Promise<{ dispose(): void }> {
55- touchProject(project.id);
56- document.title = `${project.name} — numbl web IDE`;
57-
58- const fs = await openProjectFileSystem(project.id);
59- const ide = await openNumblWorkbench(container, fs, project.name, theme);
60-
61- // the project indicator: click to go back to the project list
62- ide.workbench.statusBar.setItem('project', 'left', project.name, {
63- icon: 'folder-opened',
64- title: 'Back to projects',
65- onClick: () => { location.hash = '#/'; },
66- });
67- // replace the default branding item with the project indicator
68- ide.workbench.statusBar.removeItem('branding');
69-
70- // source control: publish this project to a new GitHub repo, or — once
71- // published — track changes and push
72- const sourceControl = await attachGitHubSourceControl(ide.workbench, fs, {
73- appName: 'numbl web IDE',
74- defaultRepoName: project.name,
75- // after publishing, seed the repo's own workspace from the local copy
76- // (no re-download — the local state IS the pushed state) and make its
77- // route the canonical place to work
78- onPublished: async ({ owner, repo }) => {
79- const ghFs = await createIndexedDBFileSystem({ dbName: githubWorkspaceDbName({ owner, repo }) });
80- try {
81- await transplantGitHubWorkspace(fs, ghFs);
82- } finally {
83- ghFs.dispose();
84- }
85- location.hash = `#/github/${owner}/${repo}`;
86- },
87- });
88-
89- await openStartingFile(fs, ide.workbench);
90-
91- return {
92- dispose() {
93- sourceControl.dispose();
94- ide.dispose();
95- fs.dispose();
96- },
97- };
98-}
src/landing.cssdeleted+0−157View file
@@ -1,157 +0,0 @@
1-/* Project-picker landing page, themed with the same --vscode-* variables as
2- * the workbench (in the spirit of VS Code's welcome page). */
3-
4-.landing {
5- height: 100%;
6- overflow-y: auto;
7- background-color: var(--vscode-editor-background);
8- color: var(--vscode-foreground);
9- font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10- font-size: 14px;
11-}
12-
13-.landing-inner {
14- max-width: 720px;
15- margin: 0 auto;
16- padding: 48px 32px 64px;
17-}
18-
19-.landing-header h1 {
20- margin: 0 0 6px;
21- font-size: 34px;
22- font-weight: 300;
23- letter-spacing: 0.5px;
24-}
25-
26-.landing-subtitle {
27- margin: 0 0 4px;
28- color: var(--vscode-descriptionForeground);
29-}
30-
31-.landing-links {
32- margin: 0;
33- color: var(--vscode-descriptionForeground);
34- font-size: 13px;
35-}
36-
37-.landing-link {
38- color: var(--vscode-textLink-foreground);
39- text-decoration: none;
40- font-size: 13px;
41-}
42-
43-.landing-link:hover {
44- text-decoration: underline;
45-}
46-
47-.landing-section {
48- margin-top: 36px;
49-}
50-
51-.landing-section h2 {
52- margin: 0 0 12px;
53- font-size: 18px;
54- font-weight: 400;
55- border-bottom: 1px solid var(--vscode-panel-border);
56- padding-bottom: 6px;
57-}
58-
59-.landing-start {
60- display: flex;
61- gap: 10px;
62-}
63-
64-.landing-button {
65- padding: 6px 14px;
66- font-size: 13px;
67- font-family: inherit;
68- cursor: pointer;
69- border-radius: 3px;
70- border: 1px solid var(--vscode-button-border, transparent);
71- background-color: var(--vscode-button-secondaryBackground);
72- color: var(--vscode-button-secondaryForeground);
73-}
74-
75-.landing-button:hover {
76- background-color: var(--vscode-button-secondaryHoverBackground);
77-}
78-
79-.landing-button.primary {
80- background-color: var(--vscode-button-background);
81- color: var(--vscode-button-foreground);
82-}
83-
84-.landing-button.primary:hover {
85- background-color: var(--vscode-button-hoverBackground);
86-}
87-
88-.landing-button:disabled {
89- opacity: 0.6;
90- cursor: default;
91-}
92-
93-.landing-projects {
94- display: flex;
95- flex-direction: column;
96-}
97-
98-.landing-empty {
99- color: var(--vscode-descriptionForeground);
100- padding: 8px 0;
101-}
102-
103-.landing-project {
104- display: flex;
105- align-items: center;
106- gap: 12px;
107- padding: 7px 8px;
108- border-radius: 4px;
109-}
110-
111-.landing-project:hover {
112- background-color: var(--vscode-list-hoverBackground);
113-}
114-
115-.landing-project-name {
116- color: var(--vscode-textLink-foreground);
117- text-decoration: none;
118- font-size: 14px;
119- overflow: hidden;
120- text-overflow: ellipsis;
121- white-space: nowrap;
122-}
123-
124-.landing-project-name:hover {
125- text-decoration: underline;
126-}
127-
128-.landing-project-meta {
129- color: var(--vscode-descriptionForeground);
130- font-size: 12px;
131- flex: 1;
132-}
133-
134-.landing-project-actions {
135- display: flex;
136- gap: 2px;
137- visibility: hidden;
138-}
139-
140-.landing-project:hover .landing-project-actions {
141- visibility: visible;
142-}
143-
144-.landing-action {
145- background: none;
146- border: none;
147- padding: 2px 7px;
148- font-size: 12px;
149- font-family: inherit;
150- cursor: pointer;
151- border-radius: 3px;
152- color: var(--vscode-textLink-foreground);
153-}
154-
155-.landing-action:hover {
156- background-color: var(--vscode-toolbar-hoverBackground);
157-}
src/landing.tsdeleted+0−157View file
@@ -1,157 +0,0 @@
1-import { applyThemeToElement, type WorkbenchTheme } from 'minwebide';
2-import { createProject, deleteProject, duplicateProject, listProjects, nextUntitledName, openProjectFileSystem, renameProject, type ProjectInfo } from './projects';
3-import { sampleWorkspace } from './sampleWorkspace';
4-import './landing.css';
5-
6-function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
7- const node = document.createElement(tag);
8- if (className) {
9- node.className = className;
10- }
11- if (text !== undefined) {
12- node.textContent = text;
13- }
14- return node;
15-}
16-
17-function openProject(id: string): void {
18- location.hash = `#/project/${id}`;
19-}
20-
21-async function createSampleProject(): Promise<ProjectInfo> {
22- const project = createProject(nextUntitledName('sample'));
23- const fs = await openProjectFileSystem(project.id);
24- try {
25- await fs.seed(sampleWorkspace);
26- } finally {
27- fs.dispose();
28- }
29- return project;
30-}
31-
32-async function createEmptyProject(): Promise<ProjectInfo> {
33- const project = createProject(nextUntitledName());
34- const fs = await openProjectFileSystem(project.id);
35- try {
36- await fs.seed({
37- '/main.m': `% ${project.name}\n% Write MATLAB-syntax code here and press the run button (▶).\n\ndisp('hello from numbl');\n`,
38- });
39- } finally {
40- fs.dispose();
41- }
42- return project;
43-}
44-
45-function formatWhen(timestamp: number): string {
46- const delta = Date.now() - timestamp;
47- if (delta < 60_000) {
48- return 'just now';
49- }
50- if (delta < 3_600_000) {
51- return `${Math.round(delta / 60_000)}m ago`;
52- }
53- if (delta < 86_400_000) {
54- return `${Math.round(delta / 3_600_000)}h ago`;
55- }
56- return new Date(timestamp).toLocaleDateString();
57-}
58-
59-/** Renders the project-picker landing page. Returns a disposable view. */
60-export function renderLanding(container: HTMLElement, theme: WorkbenchTheme): { dispose(): void } {
61- const root = el('div', 'landing');
62- applyThemeToElement(theme, root);
63- container.appendChild(root);
64- document.title = 'numbl web IDE';
65-
66- const inner = el('div', 'landing-inner');
67- root.appendChild(inner);
68-
69- const header = el('header', 'landing-header');
70- header.appendChild(el('h1', undefined, 'numbl web IDE'));
71- header.appendChild(el('p', 'landing-subtitle', 'Run MATLAB-syntax .m files in your browser. Projects are stored locally, in your browser.'));
72- const links = el('p', 'landing-links');
73- const numblLink = el('a', 'landing-link', 'numbl.org');
74- numblLink.href = 'https://numbl.org';
75- const ghLink = el('a', 'landing-link', 'github.com/concept-collection/numbl-web-ide');
76- ghLink.href = 'https://github.com/concept-collection/numbl-web-ide';
77- links.append(numblLink, ' · ', ghLink);
78- header.appendChild(links);
79- inner.appendChild(header);
80-
81- // start section
82- const start = el('section', 'landing-section');
83- start.appendChild(el('h2', undefined, 'Start'));
84- const startButtons = el('div', 'landing-start');
85- const newButton = el('button', 'landing-button primary', 'New project');
86- newButton.addEventListener('click', async () => {
87- newButton.disabled = true;
88- openProject((await createEmptyProject()).id);
89- });
90- const sampleButton = el('button', 'landing-button', 'New sample project');
91- sampleButton.title = 'Seeded with runnable scripts: plots, a 3-D surface, an animation, and file output';
92- sampleButton.addEventListener('click', async () => {
93- sampleButton.disabled = true;
94- openProject((await createSampleProject()).id);
95- });
96- startButtons.append(newButton, sampleButton);
97- start.appendChild(startButtons);
98- inner.appendChild(start);
99-
100- // projects section
101- const section = el('section', 'landing-section');
102- section.appendChild(el('h2', undefined, 'Projects'));
103- const list = el('div', 'landing-projects');
104- section.appendChild(list);
105- inner.appendChild(section);
106-
107- const renderList = () => {
108- list.textContent = '';
109- const projects = listProjects();
110- if (projects.length === 0) {
111- list.appendChild(el('div', 'landing-empty', 'No projects yet.'));
112- return;
113- }
114- for (const project of projects) {
115- const row = el('div', 'landing-project');
116-
117- const name = el('a', 'landing-project-name', project.name);
118- name.href = `#/project/${project.id}`;
119- row.appendChild(name);
120-
121- row.appendChild(el('span', 'landing-project-meta', `opened ${formatWhen(project.lastOpenedAt)}`));
122-
123- const actions = el('span', 'landing-project-actions');
124- const action = (label: string, handler: () => void | Promise<void>) => {
125- const button = el('button', 'landing-action', label);
126- button.addEventListener('click', () => handler());
127- actions.appendChild(button);
128- };
129- action('Rename', () => {
130- const name = prompt('Project name', project.name);
131- if (name !== null) {
132- renameProject(project.id, name);
133- renderList();
134- }
135- });
136- action('Duplicate', async () => {
137- await duplicateProject(project.id);
138- renderList();
139- });
140- action('Delete', async () => {
141- if (confirm(`Delete project "${project.name}" and all of its files?`)) {
142- await deleteProject(project.id);
143- renderList();
144- }
145- });
146- row.appendChild(actions);
147- list.appendChild(row);
148- }
149- };
150- renderList();
151-
152- return {
153- dispose() {
154- root.remove();
155- },
156- };
157-}
src/main.tsmodified+23−52View file
@@ -1,17 +1,26 @@
1-import { loadBuiltinTheme, registerBuiltinLanguages } from 'minwebide';
2-import { openGitHubRoute } from './githubOpen';
3-import { openIde } from './ide';
4-import { renderLanding } from './landing';
1+import { loadBuiltinTheme, registerBuiltinLanguages, startProjectApp, type ProjectAppConfig } from 'minwebide';
2+import { openNumblWorkbench } from './ide';
53 import { registerMatlabLanguage } from './numbl/language';
6-import { getProject } from './projects';
7-
8-// Routes:
9-// #/ project picker (landing page)
10-// #/project/<id> the IDE, opened on that project's file system
11-// #/github/<spec> a GitHub repo as its own workspace (owner/repo[@ref],
12-// or a URL-encoded github.com URL); imports on first
13-// visit, then keeps a local editable copy — the URL
14-// stays on this route and no project is created
4+import { sampleWorkspace } from './sampleWorkspace';
5+
6+const config: ProjectAppConfig = {
7+ appId: 'numbl-web-ide',
8+ appName: 'numbl web IDE',
9+ assembleWorkbench: openNumblWorkbench,
10+ startingFiles: ['/waves.m', '/main.m', '/README.md'],
11+ landing: {
12+ subtitle: 'Run MATLAB-syntax .m files in your browser. Projects are stored locally, in your browser.',
13+ links: [
14+ { label: 'numbl.org', href: 'https://numbl.org' },
15+ { label: 'github.com/concept-collection/numbl-web-ide', href: 'https://github.com/concept-collection/numbl-web-ide' },
16+ ],
17+ sampleWorkspace,
18+ sampleButtonTitle: 'Seeded with runnable scripts: plots, a 3-D surface, an animation, and file output',
19+ emptyWorkspace: (project) => ({
20+ '/main.m': `% ${project.name}\n% Write MATLAB-syntax code here and press the run button (▶).\n\ndisp('hello from numbl');\n`,
21+ }),
22+ },
23+};
1524
1625 async function start(): Promise<void> {
1726 // dev never uses a service worker (isolation comes from server headers) —
@@ -29,51 +38,13 @@ async function start(): Promise<void> {
2938 }
3039 }
3140
32- const app = document.getElementById('app')!;
33-
3441 // one-time global setup: theme + languages are shared by all views;
3542 // MATLAB registers last so it wins the .m extension
3643 const theme = await loadBuiltinTheme('dark_modern');
3744 await registerBuiltinLanguages(theme);
3845 registerMatlabLanguage();
3946
40- let current: { dispose(): void } | undefined;
41- let navigating = false;
42-
43- const route = async () => {
44- if (navigating) {
45- return;
46- }
47- navigating = true;
48- try {
49- current?.dispose();
50- current = undefined;
51- app.textContent = '';
52-
53- const github = location.hash.match(/^#\/github\/(.+)$/);
54- if (github) {
55- current = await openGitHubRoute(app, decodeURIComponent(github[1]), theme);
56- return;
57- }
58-
59- const match = location.hash.match(/^#\/project\/([a-z0-9]+)/i);
60- if (match) {
61- const project = getProject(match[1]);
62- if (project) {
63- current = await openIde(app, project, theme);
64- return;
65- }
66- // unknown project id: fall through to the landing page
67- history.replaceState(null, '', '#/');
68- }
69- current = renderLanding(app, theme);
70- } finally {
71- navigating = false;
72- }
73- };
74-
75- window.addEventListener('hashchange', route);
76- await route();
47+ await startProjectApp(document.getElementById('app')!, theme, config);
7748 }
7849
7950 start();
src/projects.tsdeleted+0−123View file
@@ -1,123 +0,0 @@
1-import { createIndexedDBFileSystem, type WorkspaceFileSystem } from 'minwebide';
2-
3-// The project registry: a small localStorage index of projects, each backed
4-// by its own IndexedDB database (its own workspace file system).
5-
6-export interface ProjectInfo {
7- readonly id: string;
8- name: string;
9- createdAt: number;
10- lastOpenedAt: number;
11-}
12-
13-const REGISTRY_KEY = 'numbl-web-ide.projects';
14-
15-function readRegistry(): ProjectInfo[] {
16- try {
17- const raw = localStorage.getItem(REGISTRY_KEY);
18- const parsed = raw ? JSON.parse(raw) : [];
19- return Array.isArray(parsed) ? parsed : [];
20- } catch {
21- return [];
22- }
23-}
24-
25-function writeRegistry(projects: ProjectInfo[]): void {
26- localStorage.setItem(REGISTRY_KEY, JSON.stringify(projects));
27-}
28-
29-export function projectDbName(id: string): string {
30- return `numbl-web-ide-project-${id}`;
31-}
32-
33-export function listProjects(): ProjectInfo[] {
34- return readRegistry().sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
35-}
36-
37-export function getProject(id: string): ProjectInfo | undefined {
38- return readRegistry().find(p => p.id === id);
39-}
40-
41-/** Picks 'untitled', 'untitled-2', ... skipping names already in use. */
42-export function nextUntitledName(base = 'untitled'): string {
43- const names = new Set(readRegistry().map(p => p.name));
44- if (!names.has(base)) {
45- return base;
46- }
47- for (let i = 2; ; i++) {
48- if (!names.has(`${base}-${i}`)) {
49- return `${base}-${i}`;
50- }
51- }
52-}
53-
54-export function createProject(name: string): ProjectInfo {
55- const project: ProjectInfo = {
56- id: Math.random().toString(36).slice(2, 10),
57- name,
58- createdAt: Date.now(),
59- lastOpenedAt: Date.now(),
60- };
61- writeRegistry([...readRegistry(), project]);
62- return project;
63-}
64-
65-export function renameProject(id: string, name: string): void {
66- const projects = readRegistry();
67- const project = projects.find(p => p.id === id);
68- if (project && name.trim()) {
69- project.name = name.trim();
70- writeRegistry(projects);
71- }
72-}
73-
74-export function touchProject(id: string): void {
75- const projects = readRegistry();
76- const project = projects.find(p => p.id === id);
77- if (project) {
78- project.lastOpenedAt = Date.now();
79- writeRegistry(projects);
80- }
81-}
82-
83-export async function deleteProject(id: string): Promise<void> {
84- writeRegistry(readRegistry().filter(p => p.id !== id));
85- await new Promise<void>((resolve) => {
86- const request = indexedDB.deleteDatabase(projectDbName(id));
87- request.onsuccess = request.onerror = request.onblocked = () => resolve();
88- });
89-}
90-
91-export async function openProjectFileSystem(id: string): Promise<WorkspaceFileSystem> {
92- return createIndexedDBFileSystem({ dbName: projectDbName(id) });
93-}
94-
95-/** Copies all files of one project into a brand-new project. */
96-export async function duplicateProject(id: string): Promise<ProjectInfo | undefined> {
97- const source = getProject(id);
98- if (!source) {
99- return undefined;
100- }
101- const copy = createProject(nextUntitledName(`${source.name}-copy`));
102- const sourceFs = await openProjectFileSystem(source.id);
103- const targetFs = await openProjectFileSystem(copy.id);
104- try {
105- const copyTree = async (path: string): Promise<void> => {
106- const stat = await sourceFs.fileService.resolve(sourceFs.root.with({ path }));
107- for (const child of stat.children ?? []) {
108- if (child.isDirectory) {
109- await targetFs.fileService.createFolder(targetFs.root.with({ path: child.resource.path }));
110- await copyTree(child.resource.path);
111- } else {
112- const content = await sourceFs.fileService.readFile(child.resource);
113- await targetFs.fileService.writeFile(targetFs.root.with({ path: child.resource.path }), content.value);
114- }
115- }
116- };
117- await copyTree('/');
118- } finally {
119- sourceFs.dispose();
120- targetFs.dispose();
121- }
122- return copy;
123-}