import { Editor } from '../../editor/Editor.js'; import { EditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape'; function inkscapeLabel( el: Element ): string { return el.getAttributeNS( NS_INKSCAPE, 'label' ) ?? el.getAttribute( 'inkscape:label' ) ?? ''; } // ── Types ──────────────────────────────────────────────────────────────────── interface RojoEndpoint { type: 'external' | 'tunnel' | 'claude' | 'claude-code'; url: string; model: string; apiKey: string; tunnelId: string; } interface RojoColor { name: string; value: string; target: 'fill' | 'stroke' | 'both'; } interface RojoLayer { name: string; selected: string; } interface RojoAppearance { colors: RojoColor[]; layers: RojoLayer[]; } export interface RojoSettings { id: string; name: string; description: string; systemPrompt: string; endpoint: RojoEndpoint; appearance: RojoAppearance; } function defaultSettings(): RojoSettings { return { id: '', name: '', description: '', systemPrompt: '', endpoint: { type: 'external', url: '', model: '', apiKey: '', tunnelId: '' }, appearance: { colors: [], layers: [] }, }; } // ── Name normalization ──────────────────────────────────────────────────────── function normalizeName( name: string ): string { return name .toLowerCase() .normalize( 'NFD' ) .replace( /[̀-ͯ]/g, '' ) .replace( /[^a-z0-9\s-]/g, '' ) .trim() .replace( /\s+/g, '-' ) .replace( /-+/g, '-' ); } // ── SVG processing ──────────────────────────────────────────────────────────── const CHAT_MASK_VIEWBOX = '1.5883274 0.60429424 25.973324 23.865683'; async function loadPortraitSvg(): Promise { const res = await fetch( '/rojos/rojo-base.svg' ); const text = await res.text(); const parser = new DOMParser(); const doc = parser.parseFromString( text, 'image/svg+xml' ); const svg = doc.documentElement; doc.querySelector( 'sodipodi\\:namedview, [id="namedview7"]' )?.remove(); const toRemove: Element[] = []; svg.querySelectorAll( '*' ).forEach( el => { const label = inkscapeLabel( el ); if ( label.endsWith( '-pivot' ) || label === 'chat-cutout' || label === 'chat-mask' ) { toRemove.push( el ); } } ); toRemove.forEach( el => el.remove() ); svg.setAttribute( 'viewBox', CHAT_MASK_VIEWBOX ); svg.setAttribute( 'width', '100%' ); svg.setAttribute( 'height', '100%' ); return new XMLSerializer().serializeToString( svg ); } // ── Component ──────────────────────────────────────────────────────────────── class RojoSettingsPanel extends HTMLElement { __interfaces__ = [ EditorPanelDefinition.type ]; currentPath: string | null = null; _initialized = false; _ignoreChange = false; _appearance: RojoAppearance = { colors: [], layers: [] }; _rojoId = ''; connectedCallback(): void { if ( this._initialized ) return; this._initialized = true; this.className = 'rojo-settings-panel'; this.innerHTML = `
🤖 Rojo Settings
Open a .rojo file from the file tree
`; this.querySelector( '.rsp-save' )!.addEventListener( 'click', () => this._save() ); this.querySelector( '.rsp-sync-btn' )!.addEventListener( 'click', () => this._syncFilename() ); this.querySelector( '.rsp-browse-tunnels-btn' )!.addEventListener( 'click', () => this._browseTunnels() ); this.querySelectorAll( '[data-field]' ).forEach( el => { el.addEventListener( 'input', () => this._onFieldChange() ); } ); ( this.querySelector( '[data-field="endpoint.type"]' ) as HTMLSelectElement ) .addEventListener( 'change', () => this._updateEndpointVisibility() ); Editor.get().onDocumentOpened.addListener( e => { if ( 'rojo-settings-panel' !== e.editorTag ) return; if ( e.targetElement && e.targetElement !== this ) return; this._loadDocument( e.path, e.content ); } ); document.addEventListener( 'keydown', ( e: KeyboardEvent ) => { if ( !this.currentPath ) return; if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); } } ); this._initPortrait(); } async _browseTunnels(): Promise { const picker = this.querySelector( '.rsp-tunnel-picker' ) as HTMLElement; const list = this.querySelector( '.rsp-tunnel-list' ) as HTMLElement; if ( picker.style.display !== 'none' ) { picker.style.display = 'none'; return; } list.innerHTML = '
Loading…
'; picker.style.display = ''; try { const res = await fetch( '/api/rojos/tunnels/browse' ); const data = await res.json(); if ( !res.ok || !Array.isArray( data ) ) { list.innerHTML = `
Error: ${ data?.error ?? 'Unexpected response' }
`; return; } const tunnels = data as Array<{ id: string; name: string; purpose: string; active: boolean; }>; if ( !tunnels.length ) { list.innerHTML = '
No tunnels available. Add one in the Tunnel Agent app.
'; return; } list.innerHTML = tunnels.map( t => `
${ t.name } ${ t.purpose }
` ).join( '' ); list.querySelectorAll( '.rsp-tunnel-entry' ).forEach( entry => { entry.addEventListener( 'click', () => { const id = ( entry as HTMLElement ).dataset.tunnelId ?? ''; this._setField( 'endpoint.tunnelId', id ); this._onFieldChange(); picker.style.display = 'none'; } ); } ); } catch { list.innerHTML = '
Could not reach tunnel server.
'; } } async _initPortrait(): Promise { try { const svgHtml = await loadPortraitSvg(); ( this.querySelector( '.rsp-portrait' ) as HTMLElement ).innerHTML = svgHtml; } catch {} } _loadDocument( path: string, content: string ): void { this.currentPath = path; this._updateTabLabel( path ); let settings: RojoSettings = defaultSettings(); try { settings = JSON.parse( content ); } catch {} this._rojoId = settings.id ?? ''; this._appearance = settings.appearance ?? { colors: [], layers: [] }; this._ignoreChange = true; this._populateForm( settings ); this._ignoreChange = false; ( this.querySelector( '.rsp-empty' ) as HTMLElement ).style.display = 'none'; ( this.querySelector( '.rsp-form' ) as HTMLElement ).style.display = ''; this._updateSaveButton( false ); } _populateForm( s: RojoSettings ): void { this._setField( 'name', s.name ?? '' ); this._setField( 'description', s.description ?? '' ); this._setField( 'systemPrompt', s.systemPrompt ?? '' ); this._setField( 'endpoint.type', s.endpoint?.type ?? 'external' ); this._setField( 'endpoint.url', s.endpoint?.url ?? '' ); this._setField( 'endpoint.apiKey', s.endpoint?.apiKey ?? '' ); this._setField( 'endpoint.tunnelId', s.endpoint?.tunnelId ?? '' ); this._setField( 'endpoint.model', s.endpoint?.model ?? '' ); ( this.querySelector( '.rsp-id-value' ) as HTMLElement ).textContent = s.id ? s.id : '—'; this._updateEndpointVisibility(); } _setField( field: string, value: string ): void { const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; if ( el ) el.value = value; } _getField( field: string ): string { const el = this.querySelector( `[data-field="${ field }"]` ) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; return el ? el.value : ''; } _readSettings(): RojoSettings { return { id: this._rojoId, name: this._getField( 'name' ), description: this._getField( 'description' ), systemPrompt: this._getField( 'systemPrompt' ), endpoint: { type: this._getField( 'endpoint.type' ) as 'external' | 'tunnel' | 'claude' | 'claude-code', url: this._getField( 'endpoint.url' ), apiKey: this._getField( 'endpoint.apiKey' ), tunnelId: this._getField( 'endpoint.tunnelId' ), model: this._getField( 'endpoint.model' ), }, appearance: this._appearance, }; } _onFieldChange(): void { if ( this._ignoreChange || !this.currentPath ) return; Editor.get().markDirty( this.currentPath, JSON.stringify( this._readSettings(), null, 2 ) ); this._updateSaveButton( true ); } _updateEndpointVisibility(): void { const type = this._getField( 'endpoint.type' ); const isCC = type === 'claude-code'; ( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = ( type === 'external' || type === 'claude' ) ? '' : 'none'; ( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none'; ( this.querySelector( '.rsp-endpoint-claude-code' ) as HTMLElement ).style.display = isCC ? '' : 'none'; const urlRow = this.querySelector( '.rsp-url-row' ) as HTMLElement | null; const modelRow = this.querySelector( '.rsp-model-row' ) as HTMLElement | null; if ( urlRow ) urlRow.style.display = type === 'claude' ? 'none' : ''; if ( modelRow ) modelRow.style.display = isCC ? 'none' : ''; } async _syncFilename(): Promise { if ( !this.currentPath ) return; const name = this._getField( 'name' ); const slug = normalizeName( name ); if ( !slug ) return; const newName = slug + '.rojo'; const oldPath = this.currentPath; const res = await fetch( `/api/files/${ Editor.get().projectId }/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { path: oldPath, newName } ), } ); if ( !res.ok ) return; // Derive new path: same directory, new filename const dir = oldPath.lastIndexOf( '/' ) >= 0 ? oldPath.slice( 0, oldPath.lastIndexOf( '/' ) ) : ''; const newPath = dir ? `${ dir }/${ newName }` : newName; // Migrate openDocs entry to the new path const editor = Editor.get(); const doc = editor.openDocs.get( oldPath ); if ( doc ) { editor.openDocs.set( newPath, doc ); editor.openDocs.delete( oldPath ); } this.currentPath = newPath; this._updateTabLabel( newPath ); } async _save(): Promise { if ( !this.currentPath ) return; await Editor.get().save( this.currentPath ); this._updateSaveButton( false ); } _updateTabLabel( path: string ): void { const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : ''; this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '🤖 ' + name } } ) ); } _updateSaveButton( dirty: boolean ): void { ( this.querySelector( '.rsp-save' ) as HTMLButtonElement ).disabled = !dirty; } addContextMenuEntries( dir: ContextMenuDirectory ): void { dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Rojo: ${ this.currentPath }` : 'No rojo open' ) ); } } customElements.define( 'rojo-settings-panel', RojoSettingsPanel );