From 86f339fe298971a06ec1445a932452a5a9d2c87f Mon Sep 17 00:00:00 2001 From: Karim Lamouri <15835788+klamouri@users.noreply.github.com> Date: Sat, 23 May 2026 23:57:59 -0700 Subject: [PATCH 1/2] chore: ignore JetBrains IDE folder and local AI overrides Adds .idea/ (IntelliJ), CLAUDE.local.md, and .claude/CLAUDE.local.md so personal IDE state and local-only AI prompt overrides don't leak into the repo. The shared CLAUDE.md / .claude/ stay local-only via .git/info/exclude rather than .gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 20ae41b..03750c6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,11 @@ testlogs # Data directory (runtime data) data/logs/ *.db-shm -*.db-wal \ No newline at end of file +*.db-wal + +# JetBrains IDE +.idea/ + +# Claude Code local overrides +CLAUDE.local.md +.claude/CLAUDE.local.md From 277038e3a080d7c8e7d7f497d5a91f8df6f8b725 Mon Sep 17 00:00:00 2001 From: Karim Lamouri <15835788+klamouri@users.noreply.github.com> Date: Sat, 23 May 2026 23:58:24 -0700 Subject: [PATCH 2/2] feat: folder picker UI for "Scan Specific Path" modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the free-text textarea in the Scan Specific Paths modal with a clickable folder tree. Users browse the configured SCAN_PATHS roots, expand subdirectories, and pick paths via a + button into a chip list. Backend: new GET /api/browse?path= in src/server.ts. Returns the configured SCAN_PATHS as virtual top-level entries when no path is given, otherwise enumerates immediate children of the requested path. Refuses any path that does not resolve (via realpath, on both the request and the configured roots) inside one of the configured roots, so the endpoint cannot be coaxed into leaking arbitrary filesystem structure. Returns the user-supplied path in the response rather than the realpath so the UI shows paths as the user clicked them. Frontend: hand-rolled in vanilla JS to match the project's existing no-build, no-deps frontend style — no jQuery, no tree-widget library. Lazy-loads children on expand. Folder rows have a chevron, folder icon, and + button (visible on hover). File rows are shown for context but are display-only — file icon, dimmed text, no chevron, no + (the sync engine still operates on directories, this just lets users see what's inside before picking). Selected paths render as a chip list with × to remove. Hovering a chip expands it inline so the full path is visible even when truncated. Co-Authored-By: Claude Opus 4.7 (1M context) --- public/app.js | 195 ++++++++++++++++++++++++++++++++++++++++---- public/index.html | 11 ++- public/styles.css | 203 ++++++++++++++++++++++++++++++++++++++++++++-- src/server.ts | 63 +++++++++++++- 4 files changed, 445 insertions(+), 27 deletions(-) diff --git a/public/app.js b/public/app.js index 6757b8c..94bfd5c 100644 --- a/public/app.js +++ b/public/app.js @@ -4,6 +4,7 @@ class SubsyncarrPlusClient { this.state = { currentRun: null, files: [], isRunning: false }; this.reconnectInterval = 3000; this.historyCache = {}; // Cache run data for file lookups + this.selectedPaths = []; this.initWebSocket(); this.setupEventHandlers(); @@ -202,8 +203,7 @@ class SubsyncarrPlusClient { }); document.getElementById('startCustom').addEventListener('click', () => { - document.getElementById('customPathModal').classList.remove('hidden'); - document.getElementById('customPaths').value = ''; // Clear previous input + this.openPicker(); }); document.getElementById('stopRun').addEventListener('click', () => { @@ -211,33 +211,27 @@ class SubsyncarrPlusClient { }); document.getElementById('closeModal').addEventListener('click', () => { - console.log('Close button clicked'); - document.getElementById('customPathModal').classList.add('hidden'); - document.getElementById('customPaths').value = ''; + this.closePicker(); }); document.getElementById('cancelCustom').addEventListener('click', () => { - document.getElementById('customPathModal').classList.add('hidden'); - document.getElementById('customPaths').value = ''; + this.closePicker(); }); document.getElementById('submitCustom').addEventListener('click', () => { - const paths = document - .getElementById('customPaths') - .value.split('\n') - .map((p) => p.trim()) - .filter((p) => p.length > 0); - - document.getElementById('customPathModal').classList.add('hidden'); - document.getElementById('customPaths').value = ''; + if (this.selectedPaths.length === 0) { + alert('Add at least one path before starting a scan.'); + return; + } + const paths = this.selectedPaths.slice(); + this.closePicker(); this.startRun(paths); }); // Close modal when clicking outside document.getElementById('customPathModal').addEventListener('click', (e) => { if (e.target.id === 'customPathModal') { - document.getElementById('customPathModal').classList.add('hidden'); - document.getElementById('customPaths').value = ''; + this.closePicker(); } }); @@ -314,6 +308,173 @@ class SubsyncarrPlusClient { }); } + async openPicker() { + this.selectedPaths = []; + this.renderSelectedPaths(); + const tree = document.getElementById('folderTree'); + tree.innerHTML = '
Loading…
'; + document.getElementById('customPathModal').classList.remove('hidden'); + try { + const data = await this.fetchBrowse(null); + tree.innerHTML = ''; + if (data.entries.length === 0) { + tree.innerHTML = '
No paths configured. Set SCAN_PATHS env var.
'; + return; + } + data.entries.forEach((entry) => tree.appendChild(this.makeTreeRow(entry, 0))); + } catch (err) { + tree.innerHTML = `
Failed to load: ${this.escapeHtml(err.message)}
`; + } + } + + closePicker() { + document.getElementById('customPathModal').classList.add('hidden'); + } + + async fetchBrowse(path) { + const url = path ? `/api/browse?path=${encodeURIComponent(path)}` : '/api/browse'; + const res = await fetch(url); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; + } + + makeTreeRow(entry, depth) { + const wrapper = document.createElement('div'); + wrapper.className = 'tree-node'; + + const isFile = entry.isDir === false; + + const row = document.createElement('div'); + row.className = isFile ? 'tree-row tree-row-file' : 'tree-row'; + row.style.paddingLeft = `${depth * 16}px`; + + const chevron = document.createElement('span'); + chevron.className = 'tree-chevron'; + chevron.textContent = isFile ? '' : '▶'; + + const icon = document.createElement('span'); + icon.className = 'tree-icon'; + icon.textContent = isFile ? '📄' : '📁'; + + const name = document.createElement('span'); + name.className = 'tree-name'; + name.textContent = entry.name; + name.title = entry.path; + + row.appendChild(chevron); + row.appendChild(icon); + row.appendChild(name); + + if (isFile) { + // Files are display-only — no expand, no add. + wrapper.appendChild(row); + return wrapper; + } + + const addBtn = document.createElement('button'); + addBtn.className = 'tree-add'; + addBtn.textContent = '+'; + addBtn.title = 'Add this path'; + addBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.addSelectedPath(entry.path); + }); + row.appendChild(addBtn); + + const children = document.createElement('div'); + children.className = 'tree-children hidden'; + let loaded = false; + + const toggle = async () => { + if (!loaded) { + loaded = true; + children.innerHTML = '
Loading…
'; + children.classList.remove('hidden'); + chevron.textContent = '▼'; + try { + const data = await this.fetchBrowse(entry.path); + children.innerHTML = ''; + if (data.entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'tree-empty'; + empty.style.paddingLeft = `${(depth + 1) * 16}px`; + empty.textContent = '(empty)'; + children.appendChild(empty); + } else { + data.entries.forEach((child) => children.appendChild(this.makeTreeRow(child, depth + 1))); + } + } catch (err) { + children.innerHTML = ''; + const errEl = document.createElement('div'); + errEl.className = 'tree-error'; + errEl.style.paddingLeft = `${(depth + 1) * 16}px`; + errEl.textContent = `Failed to load: ${err.message}`; + children.appendChild(errEl); + } + return; + } + const willOpen = children.classList.contains('hidden'); + children.classList.toggle('hidden'); + chevron.textContent = willOpen ? '▼' : '▶'; + }; + + chevron.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(); + }); + name.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(); + }); + + wrapper.appendChild(row); + wrapper.appendChild(children); + return wrapper; + } + + addSelectedPath(path) { + if (this.selectedPaths.includes(path)) return; + this.selectedPaths.push(path); + this.renderSelectedPaths(); + } + + removeSelectedPath(path) { + this.selectedPaths = this.selectedPaths.filter((p) => p !== path); + this.renderSelectedPaths(); + } + + renderSelectedPaths() { + const list = document.getElementById('selectedPaths'); + const empty = document.getElementById('selectedPathsEmpty'); + list.innerHTML = ''; + if (this.selectedPaths.length === 0) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + this.selectedPaths.forEach((path) => { + const li = document.createElement('li'); + li.dataset.fullpath = path; + li.title = path; + const span = document.createElement('span'); + span.className = 'selected-path-text'; + span.textContent = path; + const btn = document.createElement('button'); + btn.className = 'selected-path-remove'; + btn.textContent = '×'; + btn.title = 'Remove'; + btn.addEventListener('click', () => this.removeSelectedPath(path)); + li.appendChild(span); + li.appendChild(btn); + list.appendChild(li); + }); + } + + escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + } + async startRun(paths = null) { try { const response = await fetch('/api/run/start', { diff --git a/public/index.html b/public/index.html index 58b2547..47d201c 100644 --- a/public/index.html +++ b/public/index.html @@ -103,12 +103,19 @@

Run Logs