Beginning of File System integration

This commit is contained in:
2025-10-14 22:30:05 +02:00
parent ece02406bd
commit cec7ed8bd4
12 changed files with 2119 additions and 31 deletions

View File

@ -0,0 +1,89 @@
<script lang="ts">
import Modal from './Modal.svelte';
interface Props {
visible?: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel?: () => void;
}
let {
visible = $bindable(false),
title = 'Confirm',
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
onCancel
}: Props = $props();
function handleConfirm() {
visible = false;
onConfirm();
}
function handleCancel() {
visible = false;
onCancel?.();
}
</script>
<Modal bind:visible {title}>
<div class="confirm-dialog">
<p>{message}</p>
<div class="button-group">
<button class="button button-secondary" onclick={handleCancel}>
{cancelLabel}
</button>
<button class="button button-primary" onclick={handleConfirm}>
{confirmLabel}
</button>
</div>
</div>
</Modal>
<style>
.confirm-dialog p {
margin: 0 0 1.5rem 0;
color: rgba(255, 255, 255, 0.87);
line-height: 1.5;
}
.button-group {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.button {
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.button-primary {
background-color: #646cff;
color: white;
}
.button-primary:hover {
background-color: #535bf2;
}
.button-secondary {
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
border: 1px solid #3a3a3a;
}
.button-secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.87);
}
</style>

View File

@ -14,6 +14,7 @@
import { oneDark } from '@codemirror/theme-one-dark';
import { vim } from '@replit/codemirror-vim';
import { editorSettings } from './stores/editorSettings';
import { csound } from './csound';
interface Props {
initialValue?: string;
@ -40,6 +41,19 @@
const lineWrappingCompartment = new Compartment();
const vimCompartment = new Compartment();
const evaluateKeymap = keymap.of([
{
key: 'Mod-e',
run: (view) => {
const code = view.state.doc.toString();
csound.evaluate(code).catch(err => {
console.error('Evaluation error:', err);
});
return true;
}
}
]);
onMount(() => {
const settings = $editorSettings;
@ -75,6 +89,7 @@
...baseExtensions,
languageExtensions[language],
oneDark,
evaluateKeymap,
lineNumbersCompartment.of(settings.showLineNumbers ? lineNumbers() : []),
lineWrappingCompartment.of(settings.enableLineWrapping ? EditorView.lineWrapping : []),
vimCompartment.of(settings.vimMode ? vim() : []),

View File

@ -67,14 +67,6 @@
export function setValue(value: string): void {
editorRef?.setValue(value);
}
export function addLog(message: string): void {
logPanelRef?.addLog(message);
}
export function clearLogs(): void {
logPanelRef?.clearLogs();
}
</script>
<div class="editor-with-logs">

347
src/lib/FileBrowser.svelte Normal file
View File

@ -0,0 +1,347 @@
<script lang="ts">
import { onMount } from 'svelte';
import { File, Plus, Trash2 } from 'lucide-svelte';
import { projectManager, type CsoundProject } from './project-system';
import ConfirmDialog from './ConfirmDialog.svelte';
import InputDialog from './InputDialog.svelte';
interface Props {
onFileSelect?: (project: CsoundProject | null) => void;
onNewFile?: () => void;
onMetadataUpdate?: (projectId: string, updates: { title?: string; author?: string }) => void;
selectedProjectId?: string | null;
}
let { onFileSelect, onNewFile, onMetadataUpdate, selectedProjectId = null }: Props = $props();
let projects = $state<CsoundProject[]>([]);
let loading = $state(true);
let showDeleteConfirm = $state(false);
let projectToDelete = $state<CsoundProject | null>(null);
let selectedProject = $derived(
projects.find(p => p.id === selectedProjectId) || null
);
let editTitle = $state('');
let editAuthor = $state('');
$effect(() => {
if (selectedProject) {
editTitle = selectedProject.title;
editAuthor = selectedProject.author;
}
});
export async function refresh() {
await loadProjects();
}
onMount(async () => {
await loadProjects();
});
async function loadProjects() {
loading = true;
try {
await projectManager.init();
const result = await projectManager.getAllProjects();
if (result.success) {
projects = result.data.sort((a, b) =>
new Date(b.dateModified).getTime() - new Date(a.dateModified).getTime()
);
}
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
loading = false;
}
}
function handleNewFile() {
onNewFile?.();
}
function selectProject(project: CsoundProject) {
onFileSelect?.(project);
}
function handleDeleteClick(project: CsoundProject, event: Event) {
event.stopPropagation();
projectToDelete = project;
showDeleteConfirm = true;
}
async function confirmDelete() {
if (!projectToDelete) return;
const result = await projectManager.deleteProject(projectToDelete.id);
if (result.success) {
await loadProjects();
}
projectToDelete = null;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function handleMetadataChange() {
if (!selectedProject) return;
const hasChanges =
editTitle !== selectedProject.title ||
editAuthor !== selectedProject.author;
if (hasChanges) {
onMetadataUpdate?.(selectedProject.id, {
title: editTitle,
author: editAuthor
});
}
}
</script>
<div class="file-browser">
<div class="browser-header">
<span class="browser-title">Files</span>
<button class="action-button" onclick={handleNewFile} title="New file">
<Plus size={16} />
</button>
</div>
<div class="browser-content">
{#if loading}
<div class="empty-state">Loading...</div>
{:else}
<div class="project-list">
{#each projects as project}
<div
class="project-item"
class:selected={selectedProjectId === project.id}
onclick={() => selectProject(project)}
role="button"
tabindex="0"
>
<div class="project-icon">
<File size={16} />
</div>
<div class="project-info">
<div class="project-title">{project.title}</div>
</div>
<button
class="delete-button"
onclick={(e) => handleDeleteClick(project, e)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{#if selectedProject}
<div class="metadata-editor">
<div class="metadata-header">Metadata</div>
<div class="metadata-fields">
<div class="field">
<label for="file-title">Name</label>
<input
id="file-title"
type="text"
bind:value={editTitle}
onchange={handleMetadataChange}
/>
</div>
<div class="field">
<label for="file-author">Author</label>
<input
id="file-author"
type="text"
bind:value={editAuthor}
onchange={handleMetadataChange}
/>
</div>
</div>
</div>
{/if}
</div>
<ConfirmDialog
bind:visible={showDeleteConfirm}
title="Delete File"
message="Are you sure you want to delete '{projectToDelete?.title}'?"
confirmLabel="Delete"
onConfirm={confirmDelete}
/>
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
background-color: #1a1a1a;
}
.browser-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.browser-title {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-button {
padding: 0.25rem;
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-button:hover {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
.browser-content {
flex: 1;
overflow-y: auto;
}
.empty-state {
padding: 2rem 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.4);
}
.project-list {
display: flex;
flex-direction: column;
}
.project-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
transition: background-color 0.2s;
}
.project-item:hover {
background-color: #252525;
}
.project-item.selected {
background-color: rgba(100, 108, 255, 0.2);
border-left: 3px solid #646cff;
}
.project-icon {
color: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
}
.project-info {
flex: 1;
min-width: 0;
}
.project-title {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.87);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-button {
padding: 0.25rem;
background-color: transparent;
color: rgba(255, 255, 255, 0.4);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s;
}
.project-item:hover .delete-button {
opacity: 1;
}
.delete-button:hover {
color: rgba(255, 100, 100, 0.9);
background-color: rgba(255, 0, 0, 0.1);
}
.metadata-editor {
border-top: 1px solid #2a2a2a;
background-color: #1a1a1a;
padding: 1rem;
}
.metadata-header {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.metadata-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
}
.field input {
padding: 0.5rem;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
color: rgba(255, 255, 255, 0.87);
font-size: 0.875rem;
outline: none;
}
.field input:focus {
border-color: #646cff;
}
</style>

121
src/lib/InputDialog.svelte Normal file
View File

@ -0,0 +1,121 @@
<script lang="ts">
import Modal from './Modal.svelte';
interface Props {
visible?: boolean;
title?: string;
label: string;
placeholder?: string;
defaultValue?: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: (value: string) => void;
onCancel?: () => void;
}
let {
visible = $bindable(false),
title = 'Input',
label,
placeholder = '',
defaultValue = '',
confirmLabel = 'OK',
cancelLabel = 'Cancel',
onConfirm,
onCancel
}: Props = $props();
let value = $state(defaultValue);
function handleConfirm() {
visible = false;
onConfirm(value);
value = '';
}
function handleCancel() {
visible = false;
onCancel?.();
value = '';
}
</script>
<Modal bind:visible {title}>
<div class="input-dialog">
<label for="input-field">{label}</label>
<input
id="input-field"
type="text"
bind:value
{placeholder}
/>
<div class="button-group">
<button class="button button-secondary" onclick={handleCancel}>
{cancelLabel}
</button>
<button class="button button-primary" onclick={handleConfirm}>
{confirmLabel}
</button>
</div>
</div>
</Modal>
<style>
.input-dialog {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
color: rgba(255, 255, 255, 0.87);
font-size: 0.875rem;
}
input {
padding: 0.5rem;
background-color: #1a1a1a;
border: 1px solid #3a3a3a;
color: rgba(255, 255, 255, 0.87);
font-size: 0.875rem;
outline: none;
}
input:focus {
border-color: #646cff;
}
.button-group {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.button {
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.button-primary {
background-color: #646cff;
color: white;
}
.button-primary:hover {
background-color: #535bf2;
}
.button-secondary {
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
border: 1px solid #3a3a3a;
}
.button-secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.87);
}
</style>

View File

@ -1,28 +1,73 @@
<script lang="ts">
import { Copy, Trash2 } from 'lucide-svelte';
import { csound } from './csound';
import type { LogEntry } from './csound';
interface Props {
logs?: string[];
logs?: LogEntry[];
}
let { logs = [] }: Props = $props();
export function addLog(message: string) {
logs.push(message);
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
}
export function clearLogs() {
logs = [];
async function copyLogs() {
if (logs.length === 0) return;
const logText = logs
.map(log => `[${formatTime(log.timestamp)}] ${log.type.toUpperCase()}: ${log.message}`)
.join('\n');
try {
await navigator.clipboard.writeText(logText);
} catch (err) {
console.error('Failed to copy logs:', err);
}
}
function clearLogs() {
csound.clearLogs();
}
</script>
<div class="log-panel">
<div class="log-header">
<span class="log-title">Output</span>
<div class="log-actions">
<button
class="action-button"
onclick={copyLogs}
disabled={logs.length === 0}
title="Copy logs"
>
<Copy size={14} />
</button>
<button
class="action-button"
onclick={clearLogs}
disabled={logs.length === 0}
title="Clear logs"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div class="log-content">
{#if logs.length === 0}
<div class="empty-state">No output yet...</div>
{:else}
{#each logs as log, i}
<div class="log-entry">
<span class="log-index">{i + 1}</span>
<span class="log-message">{log}</span>
<div class="log-entry" class:error={log.type === 'error'}>
<span class="log-timestamp">{formatTime(log.timestamp)}</span>
<span class="log-message">{log.message}</span>
</div>
{/each}
{/if}
@ -37,6 +82,50 @@
background-color: #1a1a1a;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.log-title {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.log-actions {
display: flex;
gap: 0.25rem;
}
.action-button {
padding: 0.25rem;
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-button:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
.action-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.log-content {
flex: 1;
overflow-y: auto;
@ -63,10 +152,19 @@
background-color: #252525;
}
.log-index {
.log-entry.error {
background-color: rgba(255, 0, 0, 0.1);
border-left: 3px solid rgba(255, 0, 0, 0.6);
}
.log-entry.error .log-message {
color: rgba(255, 100, 100, 0.95);
}
.log-timestamp {
color: rgba(255, 255, 255, 0.4);
min-width: 2rem;
text-align: right;
min-width: 6rem;
font-size: 0.75rem;
}
.log-message {

78
src/lib/Modal.svelte Normal file
View File

@ -0,0 +1,78 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
visible?: boolean;
title?: string;
onClose?: () => void;
children: Snippet;
}
let { visible = $bindable(false), title = '', onClose, children }: Props = $props();
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
close();
}
}
function close() {
visible = false;
onClose?.();
}
</script>
{#if visible}
<div class="modal-backdrop" onclick={handleBackdropClick} role="dialog" tabindex="-1">
<div class="modal-content">
{#if title}
<div class="modal-header">
<h3>{title}</h3>
</div>
{/if}
<div class="modal-body">
{@render children()}
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
min-width: 300px;
max-width: 500px;
max-height: 80vh;
overflow: auto;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #3a3a3a;
}
.modal-header h3 {
margin: 0;
color: rgba(255, 255, 255, 0.87);
font-size: 1rem;
font-weight: 600;
}
.modal-body {
padding: 1.5rem;
}
</style>

125
src/lib/csound/engine.ts Normal file
View File

@ -0,0 +1,125 @@
import { Csound } from '@csound/browser';
export interface CsoundEngineOptions {
onMessage?: (message: string) => void;
onError?: (error: string) => void;
}
export class CsoundEngine {
private csound: Csound | null = null;
private initialized = false;
private running = false;
private options: CsoundEngineOptions;
constructor(options: CsoundEngineOptions = {}) {
this.options = options;
}
async init(): Promise<void> {
if (this.initialized) return;
try {
this.csound = await Csound();
this.csound.on('message', (message: string) => {
this.options.onMessage?.(message);
});
await this.csound.setOption('-odac');
this.initialized = true;
this.log('Csound initialized successfully');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Csound';
this.error(errorMsg);
throw error;
}
}
async evaluateCode(code: string): Promise<void> {
if (!this.initialized || !this.csound) {
throw new Error('Csound not initialized. Call init() first.');
}
try {
if (this.running) {
await this.stop();
}
this.log('Resetting Csound...');
await this.csound.reset();
this.log('Setting audio output...');
await this.csound.setOption('-odac');
const orcMatch = code.match(/<CsInstruments>([\s\S]*?)<\/CsInstruments>/);
const scoMatch = code.match(/<CsScore>([\s\S]*?)<\/CsScore>/);
if (!orcMatch || !scoMatch) {
throw new Error('Invalid CSD format. Must contain <CsInstruments> and <CsScore> sections.');
}
const orc = orcMatch[1].trim();
const sco = scoMatch[1].trim();
this.log('Compiling orchestra...');
await this.csound.compileOrc(orc);
this.log('Reading score...');
await this.csound.readScore(sco);
this.log('Starting...');
await this.csound.start();
this.log('Performing...');
this.running = true;
await this.csound.perform();
this.log('Performance complete');
this.running = false;
} catch (error) {
this.running = false;
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
this.error(errorMsg);
throw error;
}
}
async stop(): Promise<void> {
if (!this.csound || !this.running) return;
try {
await this.csound.stop();
await this.csound.reset();
this.running = false;
this.log('Stopped');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to stop';
this.error(errorMsg);
}
}
isRunning(): boolean {
return this.running;
}
isInitialized(): boolean {
return this.initialized;
}
private log(message: string): void {
this.options.onMessage?.(message);
}
private error(message: string): void {
this.options.onError?.(message);
}
async destroy(): Promise<void> {
if (this.running) {
await this.stop();
}
this.csound = null;
this.initialized = false;
}
}

4
src/lib/csound/index.ts Normal file
View File

@ -0,0 +1,4 @@
export { CsoundEngine } from './engine';
export type { CsoundEngineOptions } from './engine';
export { csound, csoundLogs, csoundInitialized, csoundRunning } from './store';
export type { LogEntry } from './store';

109
src/lib/csound/store.ts Normal file
View File

@ -0,0 +1,109 @@
import { writable, derived, get } from 'svelte/store';
import { CsoundEngine } from './engine';
export interface LogEntry {
timestamp: Date;
message: string;
type: 'info' | 'error';
}
interface CsoundState {
initialized: boolean;
running: boolean;
logs: LogEntry[];
}
function createCsoundStore() {
const initialState: CsoundState = {
initialized: false,
running: false,
logs: []
};
const { subscribe, set, update } = writable<CsoundState>(initialState);
let engine: CsoundEngine | null = null;
function addLog(message: string, type: 'info' | 'error' = 'info') {
update(state => ({
...state,
logs: [...state.logs, { timestamp: new Date(), message, type }]
}));
}
return {
subscribe,
async init() {
if (engine) return;
try {
engine = new CsoundEngine({
onMessage: (msg) => addLog(msg, 'info'),
onError: (err) => addLog(err, 'error')
});
await engine.init();
update(state => ({
...state,
initialized: true
}));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize';
addLog(errorMsg, 'error');
throw error;
}
},
async evaluate(code: string) {
if (!engine) {
throw new Error('CSound engine not initialized');
}
try {
update(state => ({ ...state, running: true }));
await engine.evaluateCode(code);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Evaluation failed';
addLog(errorMsg, 'error');
throw error;
} finally {
update(state => ({ ...state, running: false }));
}
},
async stop() {
if (!engine) return;
try {
await engine.stop();
update(state => ({ ...state, running: false }));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Stop failed';
addLog(errorMsg, 'error');
}
},
clearLogs() {
update(state => ({
...state,
logs: []
}));
},
async destroy() {
if (engine) {
await engine.destroy();
engine = null;
}
set(initialState);
}
};
}
export const csound = createCsoundStore();
export const csoundLogs = derived(csound, $csound => $csound.logs);
export const csoundInitialized = derived(csound, $csound => $csound.initialized);
export const csoundRunning = derived(csound, $csound => $csound.running);