From 99a8f591e9f30b849f603e11ad724412589dbb91 Mon Sep 17 00:00:00 2001 From: Rokojori Date: Thu, 30 Jul 2026 22:02:14 +0200 Subject: [PATCH] history: local filesystem access, remote proxy, file-tree custom elements, project-list tabs Co-Authored-By: Claude Sonnet 4.6 --- electron/main.ts | 49 ++- electron/preload.ts | 9 + .../components/editor-shell/editor-shell.ts | 4 + .../file-tree-panel/file-tree-panel.css | 109 +++++-- .../file-tree-panel/file-tree-panel.ts | 129 +++++--- .../project-list-default.css | 79 +++++ .../project-list-default.ts | 293 ++++++++++++++++-- .../tab-container/tab-container.css | 7 + .../components/tab-container/tab-container.ts | 9 +- source/editor/Editor.ts | 26 +- source/icons/directory.svg | 64 ++++ source/icons/file.svg | 65 ++++ source/server/index.ts | 7 + source/server/routes/localFiles.ts | 117 +++++++ source/server/routes/remoteProxy.ts | 46 +++ workspace/_assets_/nav-data.js | 1 + workspace/boards/bugs.html | 10 + workspace/boards/tasks.html | 38 ++- .../2026/07-July/30-Wednesday/index.html | 155 +++++++++ workspace/history/index.html | 5 + workspace/outline/index.html | 16 + 21 files changed, 1113 insertions(+), 125 deletions(-) create mode 100644 source/icons/directory.svg create mode 100644 source/icons/file.svg create mode 100644 source/server/routes/localFiles.ts create mode 100644 source/server/routes/remoteProxy.ts create mode 100644 workspace/history/2026/07-July/30-Wednesday/index.html diff --git a/electron/main.ts b/electron/main.ts index e5dd222..ef75d19 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, session } from 'electron'; import path from 'path'; import fs from 'fs'; import https from 'https'; @@ -63,6 +63,35 @@ function clearCredentials(): void { try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ } } +function localRecentsFile(): string { + return path.join( app.getPath( 'userData' ), 'local-recents.json' ); +} + +function loadLocalRecents(): string[] { + try { + const raw = fs.readFileSync( localRecentsFile(), 'utf-8' ); + return JSON.parse( raw ) as string[]; + } catch { + return []; + } +} + +function saveLocalRecents( recents: string[] ): void { + fs.writeFileSync( localRecentsFile(), JSON.stringify( recents ), 'utf-8' ); +} + +function addLocalRecent( folderPath: string ): void { + const recents = loadLocalRecents().filter( r => r !== folderPath ); + recents.unshift( folderPath ); + saveLocalRecents( recents.slice( 0, 10 ) ); +} + +function removeLocalRecent( folderPath: string ): string[] { + const recents = loadLocalRecents().filter( r => r !== folderPath ); + saveLocalRecents( recents ); + return recents; +} + // ── Network ──────────────────────────────────────────────────────────────────── function postJson( url: string, body: unknown ): Promise { @@ -140,6 +169,7 @@ async function createMainWindow(): Promise { webPreferences: { nodeIntegration: false, contextIsolation: true, + preload: path.join( __dirname, 'preload.js' ), }, title: 'Roject', } ); @@ -182,6 +212,7 @@ function loadEnv(): void { function startExpressServer(): void { loadEnv(); process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' ); + process.env.ROJECT_ELECTRON = 'true'; const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' ); // eslint-disable-next-line @typescript-eslint/no-require-imports const { startServer } = require( serverPath ) as { startServer: ( port: number ) => void }; @@ -217,6 +248,22 @@ app.whenReady().then( () => { ipcMain.handle( 'auth:last-password', () => loadLastPassword() ); ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } ); + ipcMain.handle( 'local:open-folder', async () => { + const win = mainWindow ?? BrowserWindow.getFocusedWindow(); + if ( !win ) return null; + const result = await dialog.showOpenDialog( win, { properties: [ 'openDirectory' ] } ); + if ( result.canceled || result.filePaths.length === 0 ) return null; + const folderPath = result.filePaths[ 0 ]; + addLocalRecent( folderPath ); + return folderPath; + } ); + + ipcMain.handle( 'local:get-recents', () => loadLocalRecents() ); + + ipcMain.handle( 'local:remove-recent', ( _event, folderPath: string ) => { + return removeLocalRecent( folderPath ); + } ); + ipcMain.on( 'auth:login-success', () => { // Create the main window first, close login only after it exists. // Closing login before main is ready triggers window-all-closed → app quit. diff --git a/electron/preload.ts b/electron/preload.ts index 204d715..e15c084 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -12,3 +12,12 @@ contextBridge.exposeInMainWorld( 'electronAuth', { clearCredentials: () => ipcRenderer.invoke( 'auth:clear-credentials' ), } ); + +contextBridge.exposeInMainWorld( 'electronLocal', { + openFolder: () => + ipcRenderer.invoke( 'local:open-folder' ), + getRecents: () => + ipcRenderer.invoke( 'local:get-recents' ), + removeRecent: ( folderPath: string ) => + ipcRenderer.invoke( 'local:remove-recent', folderPath ), +} ); diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts index 6d37855..3f544be 100644 --- a/source/components/editor-shell/editor-shell.ts +++ b/source/components/editor-shell/editor-shell.ts @@ -78,8 +78,12 @@ class EditorShell extends HTMLElement { const params = new URLSearchParams(location.search); const projectId = params.get('project') ?? ''; + const localRoot = params.get('localRoot') ?? ''; + const remoteProject = params.get('remoteProject') ?? ''; const projectName = params.get('name') ?? 'Project'; Editor.get().projectId = projectId; + Editor.get().localRoot = localRoot; + Editor.get().remoteProject = remoteProject; Editor.get().projectName = projectName; this.innerHTML = ` diff --git a/source/components/file-tree-panel/file-tree-panel.css b/source/components/file-tree-panel/file-tree-panel.css index 5e4ae5a..21a82ac 100644 --- a/source/components/file-tree-panel/file-tree-panel.css +++ b/source/components/file-tree-panel/file-tree-panel.css @@ -1,4 +1,5 @@ -file-tree-panel { +file-tree-panel +{ display: flex; flex-direction: column; height: 100%; @@ -7,7 +8,8 @@ file-tree-panel { font-size: 0.85rem; } -.ftp-header { +ftp-header +{ display: flex; align-items: center; gap: 4px; @@ -16,7 +18,8 @@ file-tree-panel { flex-shrink: 0; } -.ftp-btn { +.ftp-btn +{ padding: 2px 6px; background: transparent; border: 1px solid #2a2d3a; @@ -29,7 +32,8 @@ file-tree-panel { .ftp-btn:hover { color: #aaa; background: #1a1d27; border-color: #444; } -.ftp-inline-create { +ftp-inline-create +{ display: flex; flex-direction: column; gap: 4px; @@ -38,13 +42,16 @@ file-tree-panel { border-bottom: 1px solid #2a2d3a; } -.ftp-inline-label { +ftp-inline-label +{ + display: block; font-size: 0.75rem; color: #666; font-style: italic; } -.ftp-rename-input { +.ftp-rename-input +{ width: 100%; padding: 4px 6px; background: #0f1117; @@ -56,54 +63,107 @@ file-tree-panel { outline: none; } -.ftp-inline-create .ftp-btn { +ftp-inline-create .ftp-btn +{ align-self: flex-start; display: inline-block; margin-right: 4px; } -.ftp-tree { +ftp-tree +{ + display: block; flex: 1; overflow-y: auto; padding: 4px 0; } -.ftp-list { +ftp-list +{ + display: block; list-style: none; padding: 0; margin: 0; } -.ftp-list .ftp-list { +ftp-list ftp-list +{ display: none; } -.ftp-dir.open > .ftp-list { +ftp-dir +{ display: block; } -.ftp-dir-label { +ftp-dir.open > ftp-list +{ display: block; +} + +ftp-dir-label +{ + display: flex; + align-items: center; + gap: 5px; padding: 3px 8px 3px calc(8px + var(--depth, 0) * 12px); cursor: pointer; color: #7b7f96; } -.ftp-dir-label:hover { color: #c8cbde; } +ftp-dir-label::before +{ + content: ''; + display: inline-block; + flex-shrink: 0; + width: 0; + height: 0; + border-style: solid; + border-width: 4px 0 4px 6px; + border-color: transparent transparent transparent currentColor; + transition: transform 0.14s; +} -.ftp-dir.open > .ftp-dir-label { color: #c8cbde; } +ftp-dir.open > ftp-dir-label::before +{ + transform: rotate( 90deg ); +} -.ftp-file { - padding: 3px 8px 3px calc(20px + var(--depth, 0) * 12px); +ftp-dir-label:hover { color: #c8cbde; } + +ftp-dir.open > ftp-dir-label { color: #c8cbde; } + +ftp-dir, +ftp-file +{ + margin-top: 0.1em; + margin-bottom: 0.1em; +} + +ftp-file +{ + display: flex; + align-items: center; + gap: 5px; + padding: 3px 8px 3px calc( 19px + var(--depth, 0) * 12px ); cursor: pointer; color: #9ba4c7; } -.ftp-file:hover { background: #1a1d27; color: #e2e4ed; } -.ftp-file.active { background: #1e2235; color: #7c8cff; } -.ftp-dir-label.active { background: #1e2235; color: #c8cbde; } +.ftp-icon +{ + flex-shrink: 0; + width: 16px; + height: 16px; +} -.ftp-type-error { +ftp-file:hover { background: #1a1d27; color: #e2e4ed; } +ftp-file.active { background: #1e2235; color: #7c8cff; } +ftp-dir-label.active { background: #1e2235; color: #c8cbde; } + +ftp-type-error +{ + display: block; padding: 6px 10px; color: #e07070; font-size: 0.8rem; @@ -111,14 +171,17 @@ file-tree-panel { border-bottom: 1px solid #3a2a2a; } -.ftp-empty { +ftp-empty +{ display: block; padding: 12px; color: #555; font-style: italic; } -.ftp-up { +ftp-up +{ + display: block; padding: 4px 8px; cursor: pointer; color: #666; @@ -127,4 +190,4 @@ file-tree-panel { border-bottom: 1px solid #1e2030; } -.ftp-up:hover { color: #c8cbde; background: #1a1d27; } +ftp-up:hover { color: #c8cbde; background: #1a1d27; } diff --git a/source/components/file-tree-panel/file-tree-panel.ts b/source/components/file-tree-panel/file-tree-panel.ts index a4351fe..b66cd2e 100644 --- a/source/components/file-tree-panel/file-tree-panel.ts +++ b/source/components/file-tree-panel/file-tree-panel.ts @@ -22,11 +22,11 @@ class FileTreePanel extends HTMLElement { this.className = 'file-tree-panel'; this.innerHTML = ` -
+ -
-
Loading…
+ + Loading… `; this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() ); @@ -43,7 +43,7 @@ class FileTreePanel extends HTMLElement { } _updateTabLabel(): void { - const label = '📁 ' + this._dirnameDisplay(); + const label = this._dirnameDisplay(); this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) ); } @@ -73,9 +73,14 @@ class FileTreePanel extends HTMLElement { async refresh(): Promise { const state = Editor.get(); - const res = await fetch( `/api/files/${state.projectId}/tree` ); + const treeUrl = state.localRoot + ? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }` + : state.remoteProject + ? `/api/remote/files/${ state.remoteProject }/tree` + : `/api/files/${ state.projectId }/tree`; + const res = await fetch( treeUrl ); const allNodes = await res.json() as FileNode[]; - const tree = this.querySelector( '.ftp-tree' )!; + const tree = this.querySelector( 'ftp-tree' )!; let nodes: FileNode[]; @@ -90,10 +95,10 @@ class FileTreePanel extends HTMLElement { let html = ''; if ( this._rootPath ) { - html += `
[ .. ]
`; + html += `[ .. ]`; } - html += nodes.length ? this.renderNodes( nodes ) : 'Empty'; + html += nodes.length ? this.renderNodes( nodes ) : 'Empty'; tree.innerHTML = html; const upBtn = tree.querySelector( '[data-action="go-up"]' ); @@ -107,11 +112,11 @@ class FileTreePanel extends HTMLElement { bindTree( tree: Element ): void { const state = Editor.get(); - tree.querySelectorAll( '.ftp-file' ).forEach( el => { + tree.querySelectorAll( 'ftp-file' ).forEach( el => { el.addEventListener( 'click', async () => { const path = ( el as HTMLElement ).dataset.path!; this.selectedPath = path; - tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); el.classList.add( 'active' ); await state.fileEditorRegistry.load( state.projectId ); @@ -190,13 +195,13 @@ class FileTreePanel extends HTMLElement { this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY ); } ); } ); - tree.querySelectorAll( '.ftp-dir-label' ).forEach( el => { + tree.querySelectorAll( 'ftp-dir-label' ).forEach( el => { el.addEventListener( 'click', () => { - const li = el.closest( 'li' )!; - li.classList.toggle( 'open' ); + const dir = el.closest( 'ftp-dir' )!; + dir.classList.toggle( 'open' ); const path = ( el as HTMLElement ).dataset.path!; this.selectedPath = path; - tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); el.classList.add( 'active' ); } ); el.addEventListener( 'dblclick', () => { @@ -216,8 +221,8 @@ class FileTreePanel extends HTMLElement { showItemMenu( targetPath: string, x: number, y: number ): void { this.selectedPath = targetPath; - const tree = this.querySelector( '.ftp-tree' )!; - tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + const tree = this.querySelector( 'ftp-tree' )!; + tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' ); const menu = new ContextMenuDirectory( null ); @@ -230,19 +235,18 @@ class FileTreePanel extends HTMLElement { startInlineRename( oldPath: string ): void { - const tree = this.querySelector( '.ftp-tree' )!; + const tree = this.querySelector( 'ftp-tree' )!; const lastSlash = oldPath.lastIndexOf( '/' ); const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 ); - const overlay = document.createElement( 'div' ); - overlay.className = 'ftp-inline-create'; + const overlay = document.createElement( 'ftp-inline-create' ); const input = document.createElement( 'input' ); input.className = 'ftp-rename-input'; input.value = currentName; input.type = 'text'; - overlay.innerHTML = `✎ Rename: ${oldPath}`; + overlay.innerHTML = `✎ Rename: ${oldPath}`; overlay.appendChild( input ); const btnRename = document.createElement( 'button' ); @@ -280,10 +284,18 @@ class FileTreePanel extends HTMLElement { async renameEntry( oldPath: string, newName: string ): Promise { const state = Editor.get(); - const res = await fetch( `/api/files/${state.projectId}/rename`, { + const body = state.localRoot + ? { root: state.localRoot, path: oldPath, newName } + : { path: oldPath, newName }; + const url = state.localRoot + ? `/api/local/rename` + : state.remoteProject + ? `/api/remote/files/${ state.remoteProject }/rename` + : `/api/files/${ state.projectId }/rename`; + const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( { path: oldPath, newName } ), + body: JSON.stringify( body ), } ); if ( !res.ok ) return; @@ -310,10 +322,18 @@ class FileTreePanel extends HTMLElement { if ( !confirmed ) return; const state = Editor.get(); - const res = await fetch( `/api/files/${state.projectId}/delete`, { + const body = state.localRoot + ? { root: state.localRoot, path: targetPath } + : { path: targetPath }; + const url = state.localRoot + ? `/api/local/delete` + : state.remoteProject + ? `/api/remote/files/${ state.remoteProject }/delete` + : `/api/files/${ state.projectId }/delete`; + const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( { path: targetPath } ), + body: JSON.stringify( body ), } ); if ( !res.ok ) return; @@ -325,8 +345,8 @@ class FileTreePanel extends HTMLElement { resolveTargetDir(): string { if ( !this.selectedPath ) return this._rootPath; - const tree = this.querySelector( '.ftp-tree' )!; - const dirLabel = tree.querySelector( `.ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` ); + const tree = this.querySelector( 'ftp-tree' )!; + const dirLabel = tree.querySelector( `ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` ); if ( dirLabel ) return this.selectedPath; const lastSlash = this.selectedPath.lastIndexOf( '/' ); @@ -351,7 +371,13 @@ class FileTreePanel extends HTMLElement { async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise { const baseName = type === 'file' ? 'file' : 'directory'; - const res = await fetch(`/api/files/${projectId}/tree`); + const state = Editor.get(); + const treeUrl = state.localRoot + ? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }` + : state.remoteProject + ? `/api/remote/files/${ state.remoteProject }/tree` + : `/api/files/${ projectId }/tree`; + const res = await fetch( treeUrl ); const tree = await res.json() as FileNode[]; for (let i = 1; i <= 999; i++) { const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`; @@ -370,16 +396,15 @@ class FileTreePanel extends HTMLElement { } startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void { - const tree = this.querySelector('.ftp-tree')!; - const overlay = document.createElement('div'); - overlay.className = 'ftp-inline-create'; + const tree = this.querySelector('ftp-tree')!; + const overlay = document.createElement('ftp-inline-create'); const input = document.createElement('input'); input.className = 'ftp-rename-input'; input.value = defaultName; input.type = 'text'; - overlay.innerHTML = `${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}`; + overlay.innerHTML = `${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}`; overlay.appendChild(input); const btnCreate = document.createElement('button'); @@ -417,11 +442,19 @@ class FileTreePanel extends HTMLElement { async createEntry(type: 'file' | 'directory', path: string): Promise { const state = Editor.get(); const endpoint = type === 'file' ? 'create-file' : 'create-directory'; - const res = await fetch(`/api/files/${state.projectId}/${endpoint}`, { + const body = state.localRoot + ? { root: state.localRoot, path } + : { path }; + const url = state.localRoot + ? `/api/local/${ endpoint }` + : state.remoteProject + ? `/api/remote/files/${ state.remoteProject }/${ endpoint }` + : `/api/files/${ state.projectId }/${ endpoint }`; + const res = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), - }); + body: JSON.stringify( body ), + } ); if ( res.ok ) { if ( type === 'file' ) @@ -434,15 +467,18 @@ class FileTreePanel extends HTMLElement { } renderNodes(nodes: FileNode[], depth = 0): string { - return `
    ` + nodes.map(n => { + const dirIcon = ``; + const fileIcon = ``; + const sorted = [ ...nodes ].sort( ( a, b ) => a.type === b.type ? 0 : a.type === 'directory' ? -1 : 1 ); + return `` + sorted.map(n => { if (n.type === 'directory') { - return `
  • - ▸ ${n.name} + return ` + ${dirIcon}${n.name} ${this.renderNodes(n.children ?? [], depth + 1)} -
  • `; + `; } - return `
  • ${n.name}
  • `; - }).join('') + '
'; + return `${fileIcon}${n.name}`; + }).join('') + ''; } _showTypeError( filePath: string ): void @@ -451,16 +487,15 @@ class FileTreePanel extends HTMLElement { const ext = lastDot === -1 ? '' : filePath.slice( lastDot ); const msg = ext ? `Can't open extension "${ext}"` : `Can't open file without extension`; - const tree = this.querySelector( '.ftp-tree' )!; - const existing = tree.querySelector( '.ftp-type-error' ); + const tree = this.querySelector( 'ftp-tree' )!; + const existing = tree.querySelector( 'ftp-type-error' ); if ( existing ) existing.remove(); - const div = document.createElement( 'div' ); - div.className = 'ftp-type-error'; - div.textContent = msg; - tree.prepend( div ); + const el = document.createElement( 'ftp-type-error' ); + el.textContent = msg; + tree.prepend( el ); - setTimeout( () => div.remove(), 3000 ); + setTimeout( () => el.remove(), 3000 ); } } diff --git a/source/components/project-list-default/project-list-default.css b/source/components/project-list-default/project-list-default.css index 083f3b9..ddffae0 100644 --- a/source/components/project-list-default/project-list-default.css +++ b/source/components/project-list-default/project-list-default.css @@ -442,6 +442,85 @@ project-list-default { justify-content: center; } +/* ── Electron tabs ───────────────────────── */ + +pld-tabs { + display: flex; + gap: 0.2rem; + position: absolute; + left: 50%; + transform: translateX( -50% ); +} + +pld-tab { + display: inline-block; + padding: 0.4rem 1.3rem; + border-radius: 6px; + border: 1px solid transparent; + font-family: 'Barlow', sans-serif; + font-weight: 700; + font-style: italic; + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 0.82rem; + color: rgba( 255, 255, 255, 0.35 ); + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + user-select: none; +} + +pld-tab.active { + color: #fff; + background: rgba( 255, 255, 255, 0.09 ); + border-color: rgba( 255, 255, 255, 0.15 ); +} + +pld-tab:hover:not(.active) { + color: rgba( 255, 255, 255, 0.65 ); +} + +/* ── Local folder list ───────────────────── */ + +.pld-no-recents { + color: rgba( 255, 255, 255, 0.28 ); + font-size: 0.9rem; + padding: 1rem 2rem; + margin: 0; +} + +.pld-btn-remove { + position: absolute; + top: -7px; + left: -7px; + width: 24px; + height: 24px; + border-radius: 50%; + border: none; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale( 0.6 ); + transition: opacity 0.14s, transform 0.14s; + z-index: 5; + background: #b02050; + box-shadow: 0 2px 8px rgba( 0, 0, 0, 0.55 ); +} + +.pld-recent-row:hover .pld-btn-remove { + opacity: 1; + transform: scale( 1 ); +} + +.pld-btn-remove:hover { background: #e93978; } + +.pld-btn-remove svg { + width: 14px; + height: 14px; +} + /* ── Mobile ──────────────────────────────── */ @media ( max-width: 600px ) { diff --git a/source/components/project-list-default/project-list-default.ts b/source/components/project-list-default/project-list-default.ts index 302fd43..a6a355f 100644 --- a/source/components/project-list-default/project-list-default.ts +++ b/source/components/project-list-default/project-list-default.ts @@ -1,20 +1,35 @@ import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; +declare global +{ + interface Window + { + electronLocal?: { + openFolder: () => Promise; + getRecents: () => Promise; + removeRecent: ( p: string ) => Promise; + }; + } +} + const AUTH_HOST = 'https://account.rokojori.com'; const APP_URL = 'https://roject.rokojori.com'; -interface JwtUser { +interface JwtUser +{ userId: string; email: string; } -interface Project { +interface Project +{ id: string; name: string; owner_id: string; } -interface ProjectMember { +interface ProjectMember +{ id: string; project_id: string; member_type: 'user' | 'group'; @@ -22,22 +37,197 @@ interface ProjectMember { role: string; } -function hueFromId( id: string ): number { +function hueFromId( id: string ): number +{ let h = 0; - for ( let i = 0; i < id.length; i++ ) { + for ( let i = 0; i < id.length; i++ ) + { h = ( h * 31 + id.charCodeAt( i ) ) % 360; } return Math.abs( h ); } -class ProjectListDefault extends HTMLElement { - user: JwtUser | null = null; +function escapeAttr( s: string ): string +{ + return s.replace( /&/g, '&' ).replace( /"/g, '"' ); +} - async connectedCallback(): Promise { +function folderName( folderPath: string ): string +{ + const parts = folderPath.split( /[\\/]/ ).filter( Boolean ); + return parts[ parts.length - 1 ] ?? folderPath; +} + +class ProjectListDefault extends HTMLElement +{ + user: JwtUser | null = null; + _activeTab: 'local' | 'online' = 'local'; + _isRemoteOnline = false; + + private _projectUrl( path: string ): string + { + return this._isRemoteOnline ? `/api/remote/projects${ path }` : `/api/projects${ path }`; + } + + async connectedCallback(): Promise + { await this.render(); } - private async render(): Promise { + private async render(): Promise + { + if ( window.electronLocal ) + { + this._renderElectronShell(); + await this._renderTabContent(); + return; + } + await this._renderFull(); + } + + // ── Electron: shell (nav + empty content + overlays) ──────────────────── + + private _renderElectronShell(): void + { + const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`; + + this.innerHTML = ` + +
+
+
+ + + `; + + this.querySelectorAll( 'pld-tab' ).forEach( tab => + { + tab.addEventListener( 'click', async () => + { + const t = ( tab as HTMLElement ).dataset.tab as 'local' | 'online'; + if ( t === this._activeTab ) return; + this._activeTab = t; + this.querySelectorAll( 'pld-tab' ).forEach( el => el.classList.remove( 'active' ) ); + tab.classList.add( 'active' ); + await this._renderTabContent(); + } ); + } ); + } + + private async _renderTabContent(): Promise + { + const content = this.querySelector( '#pld-content' ) as HTMLElement; + if ( 'local' === this._activeTab ) + { + await this._renderLocalContent( content ); + } + else + { + await this._renderOnlineContent( content ); + } + } + + // ── Electron: local tab ───────────────────────────────────────────────── + + private async _renderLocalContent( container: HTMLElement ): Promise + { + const recents = await window.electronLocal!.getRecents(); + + container.innerHTML = ` +
+
+ Open Folder… +
+ ${ recents.map( r => this._recentFolderRowHtml( r ) ).join( '' ) } + ${ recents.length === 0 ? '

No recent folders

' : '' } + `; + + container.querySelector( '#pld-open-folder' )!.addEventListener( 'click', async () => + { + const fp = await window.electronLocal!.openFolder(); + if ( null != fp ) this._openLocalFolder( fp ); + } ); + + container.querySelectorAll( '.pld-recent-row' ).forEach( row => + { + row.addEventListener( 'click', ( e: Event ) => + { + if ( ( e.target as HTMLElement ).closest( '.pld-btn-remove' ) ) return; + this._openLocalFolder( ( row as HTMLElement ).dataset.path! ); + } ); + } ); + + container.querySelectorAll( '.pld-btn-remove' ).forEach( btn => + { + btn.addEventListener( 'click', async ( e: Event ) => + { + e.stopPropagation(); + await window.electronLocal!.removeRecent( ( btn as HTMLElement ).dataset.path! ); + await this._renderLocalContent( container ); + } ); + } ); + } + + private _recentFolderRowHtml( fp: string ): string + { + const color = `hsl( ${ hueFromId( fp ) }, 95%, 45% )`; + return ` +
+
+
+ +
+ ${ folderName( fp ).toUpperCase() } +
+ `; + } + + private _openLocalFolder( fp: string ): void + { + const name = folderName( fp ); + location.href = `/editor.html?localRoot=${ encodeURIComponent( fp ) }&name=${ encodeURIComponent( name ) }`; + } + + // ── Electron: online tab ──────────────────────────────────────────────── + + private async _renderOnlineContent( container: HTMLElement ): Promise + { + this._isRemoteOnline = true; + const res = await fetch( this._projectUrl( '' ) ); + const projects = res.ok ? await res.json() as Project[] : []; + + container.innerHTML = ` +
+
+ New Project… +
+ ${ projects.map( ( p, i ) => this.rowHtml( p, i ) ).join( '' ) } + `; + + this.bindEvents( projects ); + } + + // ── Web (non-Electron): full render ───────────────────────────────────── + + private async _renderFull(): Promise + { + this._isRemoteOnline = false; const res = await fetch( '/api/projects' ); const projects = res.ok ? await res.json() as Project[] : []; const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`; @@ -70,14 +260,17 @@ class ProjectListDefault extends HTMLElement { this.bindEvents( projects ); } - private rowHtml( p: Project, i: number ): string { + // ── Shared helpers ────────────────────────────────────────────────────── + + private rowHtml( p: Project, i: number ): string + { const color = `hsl( ${ hueFromId( p.id ) }, 95%, 45% )`; return ` -
-
Done
+ + Remote Projects in Electron + + Implemented via a reverse proxy route: /api/remote/** strips the prefix, + prepends /api, and forwards to roject.rokojori.com over HTTPS. + The Electron onBeforeSendHeaders interceptor already injects the Authorization + header on all localhost:3000 requests, so no new IPC channel was needed. + The Online tab in project-list-default fetches from /api/remote/projects + and opens the editor with a remoteProject URL param. + + + + + Local Filesystem Access + + File tree now browses arbitrary host directories via /api/local/tree, /api/local/read, + and /api/local/write routes (Node.js fs, no project storage). Editor.ts branches on + localRoot for all read/write URLs. The "This PC" tab in project-list-default opens + the editor with a localRoot URL param. + + + Tab-container: split function broken, panel border update unreliable diff --git a/workspace/history/2026/07-July/30-Wednesday/index.html b/workspace/history/2026/07-July/30-Wednesday/index.html new file mode 100644 index 0000000..2094eb3 --- /dev/null +++ b/workspace/history/2026/07-July/30-Wednesday/index.html @@ -0,0 +1,155 @@ + + + + + + Wednesday, 30 July 2026 — Roject + + + + +
+ +
+

Wednesday, 30 July 2026

+

Session History

+

Local filesystem access, remote projects proxy, file tree custom elements + icons, project-list-default tab UI.

+
+ +
+

What we built

+ +
+

project-list-default — tab UI and folder rows

+

+ The navigation in project-list-default now uses proper custom elements: + <pld-tabs> and <pld-tab> (previously + <div class="pld-tabs"> / <button class="pld-tab">). + The tabs are centred in the nav bar via + position: absolute; left: 50%; transform: translateX(-50%) + without interfering with the logo. +

+

+ The This PC tab row was rewritten to use the same style as online project rows: + pld-name class, Barlow 5em weight, and a seeded hue via + hueFromId(folder) so each local folder gets a deterministic colour. + The subtitle / path line was removed for visual consistency. +

+
+ +
+

Local Filesystem Access — completed

+

+ The Electron app can now open any host directory as an editor workspace. + The This PC tab lets the user browse and select a local folder; + clicking opens the editor with a localRoot URL param. +

+

+ Editor.ts gained a localRoot field and private + _readUrl / _writeUrl helpers that branch on + localRootremoteProjectprojectId. + editor-shell.ts reads the localRoot URL param and sets it + on the Editor singleton. file-tree-panel.ts branches on + localRoot for all tree, read, write, rename, and delete operations, + routing through the /api/local/ routes (Node.js fs). +

+

+ Root cause of the prior /api/files//tree 404 error: projectId + was '' when only localRoot was set, producing a double slash + in the URL. Fixed by the three-branch URL helper. +

+
+ +
+

Remote Projects in Electron — proxy approach

+

+ Online projects from roject.rokojori.com are now accessible in the + Electron app via a server-side reverse proxy. source/server/routes/remoteProxy.ts + is a catch-all router mounted at /api/remote: it strips the prefix, + prepends /api, and forwards the request to + roject.rokojori.com over HTTPS. +

+

+ Authentication is automatic: the Electron onBeforeSendHeaders interceptor + already injects Authorization: Bearer <token> into every + localhost:3000 request, so the proxy receives the header and forwards it + upstream with no new IPC channel needed. +

+

+ project-list-default gained an _isRemoteOnline flag and a + _projectUrl() helper that routes all API calls to /api/remote/projects + when the Online tab is active. Row clicks open the editor with a remoteProject + URL param; Editor.ts / editor-shell.ts read and apply it + the same way as localRoot. +

+
+ +
+

file-tree-panel — custom elements, icons, closed-by-default dirs

+

+ All structural HTML in the file tree was converted to hyphenated custom elements: + <ftp-header>, <ftp-tree>, <ftp-list>, + <ftp-dir>, <ftp-dir-label>, + <ftp-file>, <ftp-inline-create>, + <ftp-inline-label>, <ftp-type-error>, + <ftp-empty>, <ftp-up>. + CSS selectors, DOM queries, and closest() calls updated throughout + (e.g. el.closest('ftp-dir') instead of el.closest('li')). +

+

+ Directories now start closed. A CSS triangle indicator (border trick, + no character) on ftp-dir-label::before points right when closed and + rotates 90° on .open. Directories are sorted before files in + renderNodes. Files and directories get SVG icons via + <img src="/icons/...">; the same icons appear in + tab-container header tabs. SVG sources live in source/icons/ + and are copied to build/app/icons/ by scripts/copy-pages.js. +

+
+ +
+ +
+

Key decisions

+ +
+

+ Server-side proxy over dual-window approach for remote projects. + Option A (opening a second BrowserWindow pointed at the live site) would have + introduced version skew risk when the local Electron build diverges from the server. + Option B (proxy at /api/remote/**) keeps a single frontend, single + server, and lets onBeforeSendHeaders handle auth automatically. +

+
+ +
+

+ Three-branch URL helper instead of hardcoded paths. + _readUrl / _writeUrl check localRoot first, + then remoteProject, then fall back to projectId. This keeps + all routing in one place and prevents the double-slash /api/files//tree + error that occurred when projectId was empty. +

+
+ +
+

+ Hyphenated custom element names for all structural markup. + No <div class="name"> pattern anywhere in the file tree or + project list. CSS uses the element tag as the root selector, keeping selectors + short and scoped without class name collisions. +

+
+ +
+ +
+ Roject — session history +
+ +
+ + + + + diff --git a/workspace/history/index.html b/workspace/history/index.html index ab64f3a..9fa4e73 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -19,6 +19,11 @@

2026 — July

+
+

Wednesday, 30 July 2026

+

Local filesystem access completed (localRoot URL param, /api/local/ routes, three-branch URL helper in Editor). Remote projects proxy in Electron (/api/remote/** → roject.rokojori.com, auth via onBeforeSendHeaders). file-tree-panel fully converted to custom elements (ftp-*), closed-by-default dirs, CSS triangle, SVG icons. project-list-default tab UI with <pld-tabs>/<pld-tab> custom elements.

+
+

Friday, 25 July 2026

Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portrait→landscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.

diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 6bd24e1..af1a50f 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -398,6 +398,22 @@ session.webRequest.onBeforeSendHeaders. Run with npm run electron:dev.

+

+ Local filesystem access: when ROJECT_ELECTRON=true, + the server mounts /api/local/ routes backed by Node.js fs + (no project storage). The project-list-default This PC tab lets the user + pick any host directory; the editor opens with a localRoot URL param + and all file-tree operations route through /api/local/. +

+

+ Remote project proxy: /api/remote/** is a catch-all + that strips the prefix, prepends /api, and forwards the request to + roject.rokojori.com over HTTPS. The Authorization header + is already injected by onBeforeSendHeaders, so no extra IPC channel is + needed. The project-list-default Online tab fetches from + /api/remote/projects and opens the editor with a remoteProject + URL param. +