Fix filesystem tree handling and add file sync with Synapse

Improves path normalization, handles empty path segments, properly
identifies folders, adds two-way communication with Synapse server for
file/folder operations, and enables real-time file system exploration.
This commit is contained in:
Torsten Dittmann
2025-05-07 21:11:50 +02:00
parent dc51648b32
commit a02475dc74
9 changed files with 115 additions and 26 deletions
@@ -44,10 +44,16 @@ export function treeFromFilesystem(files: string[]): TreeItem[] {
const tree: TreeItem[] = [];
for (const path of files) {
const parts = path.split('/');
const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
if (!normalizedPath) continue;
const parts = normalizedPath.split('/');
let currentLevel = tree;
parts.forEach((part, index) => {
if (!part) return;
const existingItem = currentLevel.find((item) => item.title === part);
if (existingItem) {
@@ -56,7 +62,7 @@ export function treeFromFilesystem(files: string[]): TreeItem[] {
}
currentLevel = existingItem.children;
} else {
const isFile = index === parts.length - 1;
const isFile = index === parts.length - 1 && !path.endsWith('/');
const currentPath = parts.slice(0, index + 1).join('/');
const newItem: TreeItem = {
title: part,
@@ -13,19 +13,19 @@
} = getContext<TreeView>('tree');
</script>
{#each items as { title, icon, children, path }, i}
{@const hasChildren = !!children?.length}
{#each items as { title, icon, children, path } (path)}
{@const isFolder = children !== undefined}
{@const isRoot = level === 1}
<li style:margin-inline-start={isRoot ? '' : '1rem'}>
<button
data-file={!hasChildren}
data-file={!isFolder}
class:selected={$isSelected(path)}
use:melt={$item({
id: path,
hasChildren
hasChildren: isFolder
})}>
{#if icon === 'folder' && hasChildren && $isExpanded(path)}
{#if icon === 'folder' && isFolder && $isExpanded(path)}
<Icon icon={icons['folderOpen']} />
{:else}
<Icon icon={icons[icon]} />
+7 -4
View File
@@ -6,9 +6,10 @@
type Props = {
files: string[];
onopenfile?: (path: string) => void;
onopenfile: (path: string) => void;
onopenfolder: (path: string) => void;
};
let { files, onopenfile = null }: Props = $props();
let { files, onopenfile, onopenfolder = null }: Props = $props();
const ctx = createTreeView();
setContext('tree', ctx);
@@ -17,12 +18,14 @@
elements: { tree },
states: { selectedItem }
} = ctx;
const items = $derived(treeFromFilesystem(files));
$effect(() => {
if ($selectedItem && onopenfile) {
if ($selectedItem) {
if ($selectedItem.dataset['file'] === 'true') {
onopenfile($selectedItem.dataset['id']);
} else {
onopenfolder($selectedItem.dataset['id']);
}
}
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
export type FileSystem = Record<string, string>;
export type FileSystem = string[];
export const filesystem = writable<FileSystem>({});
export const filesystem = writable<FileSystem>([]);
+30 -7
View File
@@ -1,5 +1,9 @@
type WebSocketEvent = 'connect' | 'disconnect' | 'reconnect';
type SynapseMessageType = 'terminal' | 'fs';
type SynapseMessageType = 'terminal' | 'fs' | 'synapse';
type SynapseMessageOperations = {
operation: 'updateWorkDir';
params: { workdir: string };
};
type SynapseMessageOperationFileSystem =
| {
operation: 'createFile' | 'updateFile';
@@ -28,7 +32,7 @@ type SynapseResponseType = `${SynapseMessageType}Response`;
type Events = WebSocketEvent | SynapseResponseType;
type BaseMessage = {
success: boolean;
data: string;
data: unknown;
type: SynapseResponseType;
requestId: string;
};
@@ -41,6 +45,7 @@ export class Synapse {
> = {};
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private requestCounter = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
public isReconnecting = $state(false);
@@ -112,23 +117,41 @@ export class Synapse {
}
}
public dispatch<T extends SynapseMessageType>(
public async dispatch<T extends SynapseMessageType>(
type: T,
payload: {
synapse: SynapseMessageOperations;
fs: SynapseMessageOperationFileSystem;
terminal: SynapseMessageOperationTerminal;
}[T]
) {
): Promise<BaseMessage> {
const requestId = String(Date.now().toString() + ++this.requestCounter);
const message = {
type,
operation: payload.operation,
params: payload.params,
requestId: Date.now().toString()
requestId
};
const response = new Promise<BaseMessage>((resolve, reject) => {
if (type === 'terminal') resolve(null);
const timeout = setTimeout(() => {
reject(new Error('Request timed out'));
}, 5000);
const callback = (message: MessageEvent<string>) => {
const response = JSON.parse(message.data);
if (!this.isMessage(response)) return;
if (response.requestId === requestId) {
resolve(response);
clearTimeout(timeout);
this.ws.removeEventListener('message', callback);
}
};
this.ws.addEventListener('message', callback);
});
this.ws.send(JSON.stringify(message));
return message;
return response;
}
public addEventListener(
event: Events,
+2 -1
View File
@@ -35,11 +35,12 @@
});
synapse.addEventListener('terminalResponse', ({ message }) => {
const { data } = message;
term.write(data);
if (typeof data === 'string') term.write(data);
});
const observer = new ResizeObserver(() => {
const { cols, rows } = fitAddon.proposeDimensions();
if (term.cols === cols && term.rows === rows) return;
if (!Number.isInteger(cols) || !Number.isInteger(rows)) return;
term.resize(cols, rows);
synapse.dispatch('terminal', {
operation: 'updateSize',
+8
View File
@@ -41,6 +41,7 @@
import { filesystem } from '$lib/components/editor/filesystem';
import { previewFrameRef } from '$routes/(console)/project-[project]/store';
import { getChatWidthFromPrefs, saveChatWidthToPrefs } from '$lib/helpers/studioLayout';
import { synapse } from '$lib/components/studio/synapse.svelte';
let hasProjectSidebar = $state(false);
@@ -214,6 +215,13 @@
...$filesystem,
[action.src]: action.content
};
synapse.dispatch('fs', {
operation: 'createFile',
params: {
filepath: action.src,
content: action.content
}
});
}
});
</script>
@@ -17,6 +17,7 @@
import { synapse } from '$lib/components/studio/synapse.svelte';
import { showChat } from '$lib/stores/chat';
import { default as IconChatLayout } from '../assets/chat-layout.svelte';
import { filesystem } from '$lib/components/editor/filesystem';
const { children } = $props();
@@ -38,7 +39,31 @@
hasChildren: true
}
];
synapse.dispatch('synapse', {
operation: 'updateWorkDir',
params: {
workdir: `/artifact/${artifactId}`
}
});
synapse
.dispatch('fs', {
operation: 'getFolder',
params: {
folderpath: '.'
}
})
.then((message) => {
const data = message.data as Array<{ name: string; isDirectory: boolean }>;
if (!Array.isArray(data)) return;
for (const { name, isDirectory } of data) {
const key = isDirectory ? name + '/' : name;
filesystem.update((n) => {
n.push(key);
return n;
});
}
});
let terminalOpen = $state(getTerminalOpenFromPrefs());
let asideRef: HTMLElement;
let isResizing = false;
@@ -2,8 +2,7 @@
import Editor from '$lib/components/editor/editor.svelte';
import { filesystem } from '$lib/components/editor/filesystem';
import Filesystem from '$lib/components/editor/filesystem.svelte';
const files = $derived(Object.keys($filesystem));
import { synapse } from '$lib/components/studio/synapse.svelte';
let instance: Editor;
@@ -15,13 +14,37 @@
return 'file';
}
function openFile(path: string) {
instance?.loadCode($filesystem[path], getLanguageFromExtensions(path));
async function openFile(path: string) {
const message = await synapse.dispatch('fs', {
operation: 'getFile',
params: {
filepath: path
}
});
instance?.loadCode(message.data as string, getLanguageFromExtensions(path));
}
async function openFolder(path: string) {
const message = await synapse.dispatch('fs', {
operation: 'getFolder',
params: {
folderpath: path
}
});
const data = message.data as Array<{ name: string; isDirectory: boolean }>;
if (!Array.isArray(data)) return;
for (const { name, isDirectory } of data) {
const key = isDirectory ? name + '/' : name;
filesystem.update((n) => {
n.push(path + '/' + key);
console.log(path + '/' + key);
return n;
});
}
}
</script>
<main>
<Filesystem {files} onopenfile={openFile} />
<Filesystem files={$filesystem} onopenfile={openFile} onopenfolder={openFolder} />
<Editor bind:this={instance} />
</main>