// Brum Multi-Pane Web Environment - By Woofson
function isStandaloneMode() {
return (typeof window !== 'undefined' && window.App && (window.App.isStandalone === true || window.App.config?.server?.standalone === true || window.App.systemStatus?.standalone === true)) ||
(typeof window !== 'undefined' && (window.__TAURI__ !== undefined || window.__TAURI_INTERNALS__ !== undefined || window.__WRY__ !== undefined)) ||
(typeof document !== 'undefined' && document.body && document.body.classList.contains('standalone-mode'));
}
function getViewportType() {
if (typeof window !== 'undefined' && (window.__TAURI__ !== undefined || window.__TAURI_INTERNALS__ !== undefined || window.__WRY__ !== undefined || (window.App && window.App.isStandalone))) {
return 'pc';
}
if (typeof window !== 'undefined' && window.innerWidth <= 600) return 'phone';
if (typeof window !== 'undefined' && window.innerWidth <= 1024) return 'tablet';
return 'pc';
}
function getLayoutForViewport(vp) {
const vpType = vp || getViewportType();
if (vpType === 'phone') {
return localStorage.getItem('cd_layout_phone') || 'layout-dual-vertical';
}
if (vpType === 'tablet') {
return localStorage.getItem('cd_layout_tablet') || 'layout-dual-vertical';
}
return localStorage.getItem('cd_layout_pc') || localStorage.getItem('cd_layout') || 'layout-dual-vertical';
}
function setLayoutForViewport(layoutName, vp) {
const vpType = vp || getViewportType();
if (vpType === 'phone') {
localStorage.setItem('cd_layout_phone', layoutName);
} else if (vpType === 'tablet') {
localStorage.setItem('cd_layout_tablet', layoutName);
} else {
localStorage.setItem('cd_layout_pc', layoutName);
localStorage.setItem('cd_layout', layoutName);
}
}
// ---------------- 📐 RESIZABLE PANES & SPLITTER DIVIDERS ----------------
const DEFAULT_PANE_SPLITS = {
'dual-v': [50, 50],
'dual-h': [50, 50],
'triple': [33.333, 33.333, 33.334],
'triple-stacked-col': [50, 50],
'triple-stacked-row': [50, 50],
'quad-col': [50, 50],
'quad-row': [50, 50]
};
function getPaneSplits() {
try {
const raw = localStorage.getItem('cd_pane_splits');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return Object.assign({}, DEFAULT_PANE_SPLITS, parsed);
}
}
} catch (e) {}
return Object.assign({}, DEFAULT_PANE_SPLITS);
}
function savePaneSplits(splits) {
try {
localStorage.setItem('cd_pane_splits', JSON.stringify(splits));
} catch (e) {}
}
function applyPaneSplits(splitsToApply) {
const splits = splitsToApply || getPaneSplits();
const root = document.getElementById('panes-grid');
const docRoot = document.documentElement;
const targets = root ? [root, docRoot] : [docRoot];
targets.forEach(el => {
// Dual Vertical
const dv = splits['dual-v'] || [50, 50];
el.style.setProperty('--pane-split-dual-v-0', `${dv[0]}fr`);
el.style.setProperty('--pane-split-dual-v-1', `${dv[1]}fr`);
// Dual Horizontal
const dh = splits['dual-h'] || [50, 50];
el.style.setProperty('--pane-split-dual-h-0', `${dh[0]}fr`);
el.style.setProperty('--pane-split-dual-h-1', `${dh[1]}fr`);
// Triple (3 columns)
const t = splits['triple'] || [33.333, 33.333, 33.334];
el.style.setProperty('--pane-split-triple-0', `${t[0]}fr`);
el.style.setProperty('--pane-split-triple-1', `${t[1]}fr`);
el.style.setProperty('--pane-split-triple-2', `${t[2]}fr`);
// Triple Stacked
const tsc = splits['triple-stacked-col'] || [50, 50];
const tsr = splits['triple-stacked-row'] || [50, 50];
el.style.setProperty('--pane-split-triplestack-c0', `${tsc[0]}fr`);
el.style.setProperty('--pane-split-triplestack-c1', `${tsc[1]}fr`);
el.style.setProperty('--pane-split-triplestack-r0', `${tsr[0]}fr`);
el.style.setProperty('--pane-split-triplestack-r1', `${tsr[1]}fr`);
// Quad (2x2)
const qc = splits['quad-col'] || [50, 50];
const qr = splits['quad-row'] || [50, 50];
el.style.setProperty('--pane-split-quad-c0', `${qc[0]}fr`);
el.style.setProperty('--pane-split-quad-c1', `${qc[1]}fr`);
el.style.setProperty('--pane-split-quad-r0', `${qr[0]}fr`);
el.style.setProperty('--pane-split-quad-r1', `${qr[1]}fr`);
});
}
function attachPaneResizerHandles(paneEl, paneIndex, layout) {
if (!paneEl) return;
if (!isStandaloneMode() && getViewportType() === 'phone') return;
const currentLayout = layout || App.layout || 'layout-dual-vertical';
const createHandle = (resizerType, orientationClass, title) => {
const handle = document.createElement('div');
handle.className = `pane-resizer-handle ${orientationClass}`;
handle.setAttribute('data-resizer', resizerType);
handle.title = title || 'Drag to resize panes (Double-click to reset 50/50)';
handle.innerHTML = '<div class="splitter-grip-dots"></div>';
attachPaneResizerEvents(handle);
return handle;
};
if (currentLayout === 'layout-dual-vertical') {
if (paneIndex === 0) {
paneEl.appendChild(createHandle('dual-v', 'pane-resizer-handle-r', 'Drag to resize panes (Double-click to reset 50/50)'));
}
} else if (currentLayout === 'layout-dual-horizontal') {
if (paneIndex === 0) {
paneEl.appendChild(createHandle('dual-h', 'pane-resizer-handle-b', 'Drag to resize panes (Double-click to reset 50/50)'));
}
} else if (currentLayout === 'layout-triple') {
if (paneIndex === 0) {
paneEl.appendChild(createHandle('triple-0', 'pane-resizer-handle-r', 'Drag to resize pane 1 & 2 (Double-click to reset)'));
} else if (paneIndex === 1) {
paneEl.appendChild(createHandle('triple-1', 'pane-resizer-handle-r', 'Drag to resize pane 2 & 3 (Double-click to reset)'));
}
} else if (currentLayout === 'layout-triple-stacked') {
if (paneIndex === 0) {
paneEl.appendChild(createHandle('triplestack-col', 'pane-resizer-handle-r', 'Drag to resize columns (Double-click to reset 50/50)'));
} else if (paneIndex === 1) {
paneEl.appendChild(createHandle('triplestack-row', 'pane-resizer-handle-b', 'Drag to resize right rows (Double-click to reset 50/50)'));
}
} else if (currentLayout === 'layout-quad') {
if (paneIndex === 0) {
paneEl.appendChild(createHandle('quad-col', 'pane-resizer-handle-r', 'Drag to resize columns (Double-click to reset 50/50)'));
paneEl.appendChild(createHandle('quad-row', 'pane-resizer-handle-b', 'Drag to resize rows (Double-click to reset 50/50)'));
paneEl.appendChild(createHandle('quad-corner', 'pane-resizer-handle-corner-br', 'Drag 2D center to resize (Double-click to reset 50/50)'));
} else if (paneIndex === 1) {
paneEl.appendChild(createHandle('quad-row', 'pane-resizer-handle-b', 'Drag to resize rows (Double-click to reset 50/50)'));
} else if (paneIndex === 2) {
paneEl.appendChild(createHandle('quad-col', 'pane-resizer-handle-r', 'Drag to resize columns (Double-click to reset 50/50)'));
}
}
}
function attachPaneResizerEvents(handleEl) {
if (!handleEl) return;
const resizerType = handleEl.getAttribute('data-resizer');
const startResize = (e) => {
if (e.button !== undefined && e.button !== 0) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
const container = document.getElementById('panes-grid');
if (!container) return;
window._isResizingSplitter = true;
handleEl.classList.add('is-resizing');
document.body.classList.add('resizing-panes');
if (resizerType.includes('row')) {
document.body.style.cursor = 'row-resize';
} else if (resizerType.includes('corner')) {
document.body.style.cursor = 'move';
} else {
document.body.style.cursor = 'col-resize';
}
if (handleEl.setPointerCapture && e.pointerId !== undefined) {
try { handleEl.setPointerCapture(e.pointerId); } catch (_) {}
}
const containerRect = container.getBoundingClientRect();
const currentSplits = getPaneSplits();
const getCoords = (ev) => {
let x = ev.clientX;
let y = ev.clientY;
if ((x === undefined || y === undefined) && ev.touches && ev.touches.length > 0) {
x = ev.touches[0].clientX;
y = ev.touches[0].clientY;
} else if ((x === undefined || y === undefined) && ev.changedTouches && ev.changedTouches.length > 0) {
x = ev.changedTouches[0].clientX;
y = ev.changedTouches[0].clientY;
}
return { x, y };
};
const onMove = (moveEvt) => {
if (!window._isResizingSplitter) return;
if (moveEvt.cancelable) moveEvt.preventDefault();
moveEvt.stopPropagation();
const { x, y } = getCoords(moveEvt);
if (x === undefined || y === undefined) return;
const relX = x - containerRect.left;
const relY = y - containerRect.top;
if (resizerType === 'dual-v') {
const minPct = Math.max(10, (120 / containerRect.width) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relX / containerRect.width) * 100, minPct), maxPct);
currentSplits['dual-v'] = [pct, 100 - pct];
} else if (resizerType === 'dual-h') {
const minPct = Math.max(10, (80 / containerRect.height) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relY / containerRect.height) * 100, minPct), maxPct);
currentSplits['dual-h'] = [pct, 100 - pct];
} else if (resizerType === 'triple-0') {
const triple = currentSplits['triple'] || [33.333, 33.333, 33.334];
const sum01 = triple[0] + triple[1];
const min0 = Math.max(10, (120 / containerRect.width) * 100);
const max0 = sum01 - min0;
const pct0 = Math.min(Math.max((relX / containerRect.width) * 100, min0), max0);
const pct1 = sum01 - pct0;
currentSplits['triple'] = [pct0, pct1, triple[2]];
} else if (resizerType === 'triple-1') {
const triple = currentSplits['triple'] || [33.333, 33.333, 33.334];
const sum12 = triple[1] + triple[2];
const pct0_plus_1 = (relX / containerRect.width) * 100;
const rawPct1 = pct0_plus_1 - triple[0];
const min1 = Math.max(10, (120 / containerRect.width) * 100);
const max1 = sum12 - min1;
const pct1 = Math.min(Math.max(rawPct1, min1), max1);
const pct2 = sum12 - pct1;
currentSplits['triple'] = [triple[0], pct1, pct2];
} else if (resizerType === 'triplestack-col') {
const minPct = Math.max(10, (120 / containerRect.width) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relX / containerRect.width) * 100, minPct), maxPct);
currentSplits['triple-stacked-col'] = [pct, 100 - pct];
} else if (resizerType === 'triplestack-row') {
const minPct = Math.max(10, (80 / containerRect.height) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relY / containerRect.height) * 100, minPct), maxPct);
currentSplits['triple-stacked-row'] = [pct, 100 - pct];
} else if (resizerType === 'quad-col') {
const minPct = Math.max(10, (120 / containerRect.width) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relX / containerRect.width) * 100, minPct), maxPct);
currentSplits['quad-col'] = [pct, 100 - pct];
} else if (resizerType === 'quad-row') {
const minPct = Math.max(10, (80 / containerRect.height) * 100);
const maxPct = 100 - minPct;
const pct = Math.min(Math.max((relY / containerRect.height) * 100, minPct), maxPct);
currentSplits['quad-row'] = [pct, 100 - pct];
} else if (resizerType === 'quad-corner') {
const minPctX = Math.max(10, (120 / containerRect.width) * 100);
const maxPctX = 100 - minPctX;
const pctX = Math.min(Math.max((relX / containerRect.width) * 100, minPctX), maxPctX);
currentSplits['quad-col'] = [pctX, 100 - pctX];
const minPctY = Math.max(10, (80 / containerRect.height) * 100);
const maxPctY = 100 - minPctY;
const pctY = Math.min(Math.max((relY / containerRect.height) * 100, minPctY), maxPctY);
currentSplits['quad-row'] = [pctY, 100 - pctY];
}
applyPaneSplits(currentSplits);
};
const onEnd = (upEvt) => {
window._isResizingSplitter = false;
handleEl.classList.remove('is-resizing');
document.body.classList.remove('resizing-panes');
document.body.style.cursor = '';
if (handleEl.releasePointerCapture && upEvt && upEvt.pointerId !== undefined) {
try { handleEl.releasePointerCapture(upEvt.pointerId); } catch (_) {}
}
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onEnd, { capture: true });
window.removeEventListener('pointercancel', onEnd, { capture: true });
window.removeEventListener('mousemove', onMove, { capture: true });
window.removeEventListener('mouseup', onEnd, { capture: true });
window.removeEventListener('touchmove', onMove, { capture: true });
window.removeEventListener('touchend', onEnd, { capture: true });
window.removeEventListener('touchcancel', onEnd, { capture: true });
document.removeEventListener('pointermove', onMove, { capture: true });
document.removeEventListener('pointerup', onEnd, { capture: true });
document.removeEventListener('mousemove', onMove, { capture: true });
document.removeEventListener('mouseup', onEnd, { capture: true });
document.removeEventListener('touchmove', onMove, { capture: true });
document.removeEventListener('touchend', onEnd, { capture: true });
savePaneSplits(currentSplits);
queueSaveUserPreferencesToServer();
window.dispatchEvent(new Event('resize'));
if (typeof applyAllColumnWidths === 'function') {
applyAllColumnWidths();
}
};
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
window.addEventListener('pointerup', onEnd, { capture: true, passive: false });
window.addEventListener('pointercancel', onEnd, { capture: true, passive: false });
window.addEventListener('mousemove', onMove, { capture: true, passive: false });
window.addEventListener('mouseup', onEnd, { capture: true, passive: false });
window.addEventListener('touchmove', onMove, { capture: true, passive: false });
window.addEventListener('touchend', onEnd, { capture: true, passive: false });
window.addEventListener('touchcancel', onEnd, { capture: true, passive: false });
document.addEventListener('pointermove', onMove, { capture: true, passive: false });
document.addEventListener('pointerup', onEnd, { capture: true, passive: false });
document.addEventListener('mousemove', onMove, { capture: true, passive: false });
document.addEventListener('mouseup', onEnd, { capture: true, passive: false });
document.addEventListener('touchmove', onMove, { capture: true, passive: false });
document.addEventListener('touchend', onEnd, { capture: true, passive: false });
};
handleEl.addEventListener('pointerdown', startResize, { passive: false });
handleEl.addEventListener('mousedown', startResize);
handleEl.addEventListener('touchstart', startResize, { passive: false });
// Double Click Reset to Default
handleEl.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
resetPaneSplit(resizerType);
});
}
function resetPaneSplit(resizerType) {
const currentSplits = getPaneSplits();
if (resizerType === 'dual-v') {
currentSplits['dual-v'] = [50, 50];
} else if (resizerType === 'dual-h') {
currentSplits['dual-h'] = [50, 50];
} else if (resizerType === 'triple-0' || resizerType === 'triple-1') {
currentSplits['triple'] = [33.333, 33.333, 33.334];
} else if (resizerType === 'triplestack-col') {
currentSplits['triple-stacked-col'] = [50, 50];
} else if (resizerType === 'triplestack-row') {
currentSplits['triple-stacked-row'] = [50, 50];
} else if (resizerType === 'quad-col') {
currentSplits['quad-col'] = [50, 50];
} else if (resizerType === 'quad-row') {
currentSplits['quad-row'] = [50, 50];
} else if (resizerType === 'quad-corner') {
currentSplits['quad-col'] = [50, 50];
currentSplits['quad-row'] = [50, 50];
} else {
Object.assign(currentSplits, DEFAULT_PANE_SPLITS);
}
applyPaneSplits(currentSplits);
savePaneSplits(currentSplits);
queueSaveUserPreferencesToServer();
window.dispatchEvent(new Event('resize'));
showToast('Pane split reset to default 50/50', 'info');
}
var App = {
panes: [],
activePaneIndex: 0,
layout: getLayoutForViewport(),
paranoidMode: true,
token: localStorage.getItem('cd_token') || '',
user: null,
config: null,
contextItem: null,
contextPaneIndex: null,
systemUsers: [],
systemGroups: [],
showHiddenDefault: localStorage.getItem('cd_show_hidden') !== 'false',
showFKeyBar: localStorage.getItem('cd_show_fkeys') !== 'false',
fontSize: parseInt(localStorage.getItem('cd_font_size') || '13', 10),
quickDestinations: JSON.parse(localStorage.getItem('cd_quick_destinations') || 'null') || [
{ name: 'Home (~)', path: '~' },
{ name: 'Downloads', path: '~/Downloads' },
{ name: 'Documents', path: '~/Documents' },
{ name: 'Desktop', path: '~/Desktop' },
{ name: 'Temporary Files (/tmp)', path: '/tmp' },
],
clipboard: null,
dblclickUpDir: localStorage.getItem('cd_dblclick_up') !== 'false',
showParentDir: localStorage.getItem('cd_show_parent_dir') !== 'false',
autoOpenTasks: localStorage.getItem('cd_auto_open_tasks') !== 'false',
taskVerbosity: localStorage.getItem('cd_task_verbosity') || 'detailed',
trashEnabled: localStorage.getItem('cd_trash_enabled') !== 'false',
customTrashDir: localStorage.getItem('cd_custom_trash_dir') || '',
iconTheme: localStorage.getItem('cd_icon_theme') || 'default',
globalFolderIcon: (localStorage.getItem('cd_global_folder_icon') && !localStorage.getItem('cd_global_folder_icon').includes('.webp') && !localStorage.getItem('cd_global_folder_icon').includes('assets/')) ? localStorage.getItem('cd_global_folder_icon') : '',
customFileIcons: JSON.parse(localStorage.getItem('cd_custom_file_icons') || '{}'),
dndDefaultAction: localStorage.getItem('cd_dnd_default_action') || 'ask',
dndPromptMode: localStorage.getItem('cd_dnd_prompt_mode') || 'ask',
dndParanoidPrompt: localStorage.getItem('cd_dnd_paranoid_prompt') !== 'false',
paneReorderEnabled: localStorage.getItem('cd_pane_reorder_enabled') !== 'false',
activateOnHover: localStorage.getItem('cd_activate_on_hover') === '1' || localStorage.getItem('cd_activate_on_hover') === 'true',
phoneTabletMaxPanes: parseInt(localStorage.getItem('cd_phone_tablet_max_panes') || '2', 10),
paneTabsMode: localStorage.getItem('cd_pane_tabs_mode') || 'pc_only',
hapticFeedback: localStorage.getItem('cd_haptic_feedback') !== 'false',
syncScroll: localStorage.getItem('cd_sync_scroll') === 'true',
syncNav: localStorage.getItem('cd_sync_nav') === 'true',
};
window.App = App;
let ColumnConfig = {
widths: JSON.parse(localStorage.getItem('cd_col_widths') || '{}'),
visibility: JSON.parse(localStorage.getItem('cd_col_visibility') || '{"name":true,"ext":false,"size":true,"modified":true,"created":false,"mode":true,"owner":true,"group":false,"hash":false,"tags":false}'),
};
// ---------------- TOOLS & LAUNCHPAD MENU CUSTOMIZER ----------------
const DEFAULT_TOOLS_MENU = [
{ id: 'notedog', label: 'Notes', icon: 'assets/note.webp', action: 'openFloatingNoteDog()', desc: 'Notes, checklists, templates & markdown studio', visible: true },
{ id: 'calc', label: 'Calculator', icon: 'assets/calc.webp', action: 'openFloatingCalculator()', desc: 'Storage units, conversions & live history', visible: true },
{ id: 'renamer', label: 'Batch Renamer', icon: 'file-signature', iconColor: 'var(--accent)', action: 'openBatchRenamer()', desc: 'Pattern replacements, regex capture groups & sequences (Ctrl+M)', visible: true },
{ id: 'hexeditor', label: 'Hex Editor', icon: 'binary', iconColor: 'var(--accent)', action: 'openHexEditor()', desc: 'Binary byte inspector, patching & checksum calculator', visible: true },
{ id: 'splitter', label: 'File Splitter & Combiner', icon: 'scissors', iconColor: 'var(--accent)', action: 'openFileSplitterModal()', desc: 'Multi-part chunk splitter and checksum verifier/joiner', visible: true },
{ id: 'terminal', label: 'Terminal', icon: 'assets/term.webp', action: 'toggleTerminal()', desc: 'Interactive slide-up & floating PTY shell (`)', visible: true },
{ id: 'editor', label: 'Editor', icon: 'assets/edit.webp', action: 'openFloatingEditor()', desc: 'Multi-tab text and code editor with syntax mode (F4)', visible: true },
{ id: 'diff', label: 'Compare', icon: 'assets/diff.webp', action: 'triggerDiff()', desc: 'Visual side-by-side file and folder diff (F9)', visible: true },
{ id: 'search', label: 'Search', icon: 'assets/search.webp', action: 'openSearchModal()', desc: 'Recursive filename, regex & size filter (Ctrl+F)', visible: true },
{ id: 'shares', label: 'Share Manager', icon: 'assets/sharemgr.webp', action: 'openSharesManager()', desc: 'Manage public share links and guest dropboxes', visible: true },
{ id: 'sync', label: 'Backup & Sync', icon: 'assets/sync.webp', action: 'openSyncModal()', desc: 'Two-way sync, mirrors, snapshot archives & replication', visible: true },
{ id: 'du', label: 'Disk Usage', icon: 'assets/amber-piechart.webp', action: 'openDiskUsageModal()', desc: 'Treemap visualizer and heavy space consumer analyzer', visible: true },
{ id: 'syncthing', label: 'Syncthing', icon: 'assets/syncthing.webp', action: 'openSyncthingModal()', desc: 'Continuous peer-to-peer file synchronization', visible: true },
{ id: 'converter', label: 'Format Converter', icon: 'assets/convertx.webp', action: 'openConverterModal()', desc: 'Batch file format conversions for media & docs', visible: true },
{ id: 'pdf', label: 'PDF Studio', icon: 'assets/amber-pdftool.webp', action: 'openPdfToolModal()', desc: 'Merge, split, extract pages & inspect PDFs', visible: true },
{ id: 'cad', label: '3D CAD Studio', icon: 'box', iconColor: 'var(--accent)', action: 'openCadStudio()', desc: 'Interactive 3D model & CAD viewer (STL, OBJ, GLTF, 3MF, STEP, DXF)', visible: true },
{ id: 'duplicates', label: 'Duplicate Finder', icon: 'copy-check', iconColor: 'var(--accent)', action: 'openDuplicateFinder()', desc: 'Multi-stage size, partial & SHA-256 duplicate scanner and safe quarantine cleaner', visible: true },
{ id: 'tageditor', label: 'Tag Editor', icon: 'tag', iconColor: 'var(--accent)', action: 'openTagEditor()', desc: 'Audio ID3/FLAC metadata & artwork editor, batch sequential auto-numberer & EXIF inspector', visible: true },
{ id: 'logviewer', label: 'Log Viewer', icon: 'scroll-text', iconColor: 'var(--accent)', action: 'openLogViewer()', desc: 'Real-time log tailing, regex & inverted filter, log level parsing & autoscroll', visible: true },
{ id: 'mediaplayer', label: 'Media Player', icon: 'assets/media.webp', action: 'openMediaPlayer()', desc: 'Universal video player, subtitles, PiP & playlist', visible: true },
{ id: 'sounddog', label: 'Audio Player', icon: 'assets/amber-media.webp', action: 'openSoundDog()', desc: 'Audio player, jukebox, playlists & 10-band equalizer', visible: true },
{ id: 'tetradog', label: 'Tetris', icon: 'assets/amber-tetris.webp', action: 'openTetraDog()', desc: 'Classic arcade block puzzle', visible: true }
];
// ---------------- FILE TEMPLATES ENGINE ----------------
const DEFAULT_FILE_TEMPLATES = [
{
id: 'text',
name: 'Plain Text Document (.txt)',
ext: 'txt',
icon: 'file-text',
defaultFilename: 'document.txt',
content: 'Title: {{TITLE}}\nDate: {{DATE}}\nAuthor: {{USER}}\n--------------------------------------------------\n\n'
},
{
id: 'markdown',
name: 'Markdown Document (.md)',
ext: 'md',
icon: 'file-code',
defaultFilename: 'document.md',
content: '# {{TITLE}}\n\n> Created on {{DATE}} by {{USER}}\n\n## Overview\n\n\n## Notes\n\n'
},
{
id: 'html',
name: 'HTML5 Webpage (.html)',
ext: 'html',
icon: 'globe',
defaultFilename: 'index.html',
content: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>{{TITLE}}</title>\n <style>\n body {\n font-family: system-ui, -apple-system, sans-serif;\n margin: 2rem;\n background: #121316;\n color: #e4e4e7;\n }\n </style>\n</head>\n<body>\n <h1>{{TITLE}}</h1>\n <p>Generated on {{DATE}} by {{USER}}.</p>\n</body>\n</html>\n'
},
{
id: 'css',
name: 'CSS Stylesheet (.css)',
ext: 'css',
icon: 'palette',
defaultFilename: 'styles.css',
content: '/**\n * {{FILENAME}} - {{TITLE}}\n * Created: {{DATE}} by {{USER}}\n */\n\n:root {\n --accent: #f59e0b;\n --bg: #121316;\n --text: #e4e4e7;\n}\n\n* {\n box-sizing: border-box;\n}\n'
},
{
id: 'js',
name: 'JavaScript Module (.js)',
ext: 'js',
icon: 'file-code',
defaultFilename: 'app.js',
content: '/**\n * {{FILENAME}} - {{TITLE}}\n * Created: {{DATE}} by {{USER}}\n */\n\nexport function init() {\n console.log("Initialized {{TITLE}} on {{DATE}}");\n}\n\ninit();\n'
},
{
id: 'ts',
name: 'TypeScript (.ts)',
ext: 'ts',
icon: 'file-code',
defaultFilename: 'index.ts',
content: '/**\n * {{FILENAME}} - {{TITLE}}\n * Created: {{DATE}} by {{USER}}\n */\n\nexport interface Config {\n title: string;\n createdAt: string;\n author: string;\n}\n\nexport const config: Config = {\n title: "{{TITLE}}",\n createdAt: "{{ISO_DATE}}",\n author: "{{USER}}"\n};\n'
},
{
id: 'python',
name: 'Python Script (.py)',
ext: 'py',
icon: 'file-code',
defaultFilename: 'script.py',
content: '#!/usr/bin/env python3\n"""\n{{FILENAME}} - {{TITLE}}\nCreated: {{DATE}} by {{USER}}\n"""\n\ndef main():\n print("Hello from {{TITLE}}!")\n\nif __name__ == "__main__":\n main()\n'
},
{
id: 'rust',
name: 'Rust Source (.rs)',
ext: 'rs',
icon: 'file-code',
defaultFilename: 'main.rs',
content: '//! {{FILENAME}} - {{TITLE}}\n//! Created: {{DATE}} by {{USER}}\n\nfn main() {\n println!("Hello from {{TITLE}}!");\n}\n'
},
{
id: 'shell',
name: 'Bash Script (.sh)',
ext: 'sh',
icon: 'terminal',
defaultFilename: 'script.sh',
content: '#!/usr/bin/env bash\n# {{FILENAME}} - {{TITLE}}\n# Created: {{DATE}} by {{USER}}\n\nset -euo pipefail\n\necho "Running {{TITLE}}..."\n'
},
{
id: 'json',
name: 'JSON Document (.json)',
ext: 'json',
icon: 'file-text',
defaultFilename: 'data.json',
content: '{\n "title": "{{TITLE}}",\n "created_at": "{{ISO_DATE}}",\n "author": "{{USER}}",\n "version": "1.0"\n}\n'
},
{
id: 'yaml',
name: 'YAML Document (.yaml)',
ext: 'yaml',
icon: 'file-text',
defaultFilename: 'config.yaml',
content: '# {{TITLE}}\n# Created: {{DATE}} by {{USER}}\nversion: "1.0"\ntitle: "{{TITLE}}"\ncreated_at: "{{ISO_DATE}}"\nauthor: "{{USER}}"\n'
}
];
// ---------------- PANE IDENTIFICATION BORDER COLORS (HYPRLAND-STYLE TWO-TONE) ----------------
const PANE_COLOR_PALETTE = ['default', 'amber', 'emerald', 'sky', 'purple', 'rose', 'indigo', 'teal', 'orange'];
const PANE_COLOR_PRESETS = {
'default': {
id: 'default',
name: 'Default',
c1: '#f59e0b',
c2: '#ea580c',
inactiveC1: 'rgba(255, 255, 255, 0.18)',
inactiveC2: 'rgba(245, 158, 11, 0.25)',
activeC1: '#f59e0b',
activeC2: '#ea580c',
glow: 'rgba(245, 158, 11, 0.35)',
badgeBg: 'rgba(245, 158, 11, 0.12)'
},
'amber': {
id: 'amber',
name: 'Amber Gold',
c1: '#f59e0b',
c2: '#f97316',
inactiveC1: 'rgba(245, 158, 11, 0.45)',
inactiveC2: 'rgba(249, 115, 22, 0.45)',
activeC1: '#f59e0b',
activeC2: '#f97316',
glow: 'rgba(245, 158, 11, 0.35)',
badgeBg: 'rgba(245, 158, 11, 0.12)'
},
'emerald': {
id: 'emerald',
name: 'Emerald Cyan',
c1: '#10b981',
c2: '#06b6d4',
inactiveC1: 'rgba(16, 185, 129, 0.45)',
inactiveC2: 'rgba(6, 182, 212, 0.45)',
activeC1: '#10b981',
activeC2: '#06b6d4',
glow: 'rgba(16, 185, 129, 0.35)',
badgeBg: 'rgba(16, 185, 129, 0.12)'
},
'sky': {
id: 'sky',
name: 'Sky Indigo',
c1: '#38bdf8',
c2: '#818cf8',
inactiveC1: 'rgba(56, 189, 248, 0.45)',
inactiveC2: 'rgba(129, 140, 248, 0.45)',
activeC1: '#38bdf8',
activeC2: '#818cf8',
glow: 'rgba(56, 189, 248, 0.35)',
badgeBg: 'rgba(56, 189, 248, 0.12)'
},
'purple': {
id: 'purple',
name: 'Purple Rose',
c1: '#c084fc',
c2: '#f43f5e',
inactiveC1: 'rgba(192, 132, 252, 0.45)',
inactiveC2: 'rgba(244, 63, 94, 0.45)',
activeC1: '#c084fc',
activeC2: '#f43f5e',
glow: 'rgba(192, 132, 252, 0.35)',
badgeBg: 'rgba(192, 132, 252, 0.12)'
},
'rose': {
id: 'rose',
name: 'Rose Coral',
c1: '#f43f5e',
c2: '#fb923c',
inactiveC1: 'rgba(244, 63, 94, 0.45)',
inactiveC2: 'rgba(251, 146, 60, 0.45)',
activeC1: '#f43f5e',
activeC2: '#fb923c',
glow: 'rgba(244, 63, 94, 0.35)',
badgeBg: 'rgba(244, 63, 94, 0.12)'
},
'indigo': {
id: 'indigo',
name: 'Indigo Violet',
c1: '#6366f1',
c2: '#a855f7',
inactiveC1: 'rgba(99, 102, 241, 0.45)',
inactiveC2: 'rgba(168, 85, 247, 0.45)',
activeC1: '#6366f1',
activeC2: '#a855f7',
glow: 'rgba(99, 102, 241, 0.35)',
badgeBg: 'rgba(99, 102, 241, 0.12)'
},
'teal': {
id: 'teal',
name: 'Teal Mint',
c1: '#14b8a6',
c2: '#10b981',
inactiveC1: 'rgba(20, 184, 166, 0.45)',
inactiveC2: 'rgba(16, 185, 129, 0.45)',
activeC1: '#14b8a6',
activeC2: '#10b981',
glow: 'rgba(20, 184, 166, 0.35)',
badgeBg: 'rgba(20, 184, 166, 0.12)'
},
'orange': {
id: 'orange',
name: 'Orange Amber',
c1: '#f97316',
c2: '#eab308',
inactiveC1: 'rgba(249, 115, 22, 0.45)',
inactiveC2: 'rgba(234, 179, 8, 0.45)',
activeC1: '#f97316',
activeC2: '#eab308',
glow: 'rgba(249, 115, 22, 0.35)',
badgeBg: 'rgba(249, 115, 22, 0.12)'
}
};
function hexToRgba(color, alpha = 1) {
if (!color) return `rgba(245, 158, 11, ${alpha})`;
if (color.startsWith('rgba')) return color;
if (color.startsWith('rgb(')) {
return color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`);
}
let hex = color.replace('#', '').trim();
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
if (hex.length === 6) {
const num = parseInt(hex, 16);
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return color;
}
function resolvePaneColorConfig(colorConf, globalAngle = '45deg') {
if (!colorConf || colorConf === 'default') {
const p = PANE_COLOR_PRESETS.default;
return {
...p,
angle: globalAngle,
isCustom: false
};
}
if (typeof colorConf === 'object' && colorConf !== null) {
const c1 = colorConf.c1 || '#f59e0b';
const c2 = colorConf.c2 || c1;
const angle = colorConf.angle || globalAngle;
return {
id: 'custom',
name: 'Custom',
c1,
c2,
inactiveC1: hexToRgba(c1, 0.45),
inactiveC2: hexToRgba(c2, 0.45),
activeC1: c1,
activeC2: c2,
glow: hexToRgba(c1, 0.35),
badgeBg: hexToRgba(c1, 0.12),
angle,
isCustom: true
};
}
if (typeof colorConf === 'string') {
if (PANE_COLOR_PRESETS[colorConf]) {
return {
...PANE_COLOR_PRESETS[colorConf],
angle: globalAngle,
isCustom: false
};
}
if (colorConf.includes(',')) {
const parts = colorConf.split(',');
const c1 = parts[0].trim();
const c2 = (parts[1] || parts[0]).trim();
return {
id: 'custom',
name: 'Custom',
c1,
c2,
inactiveC1: hexToRgba(c1, 0.45),
inactiveC2: hexToRgba(c2, 0.45),
activeC1: c1,
activeC2: c2,
glow: hexToRgba(c1, 0.35),
badgeBg: hexToRgba(c1, 0.12),
angle: globalAngle,
isCustom: true
};
}
if (colorConf.startsWith('#') || colorConf.startsWith('rgb')) {
const c1 = colorConf.trim();
return {
id: 'custom',
name: 'Custom',
c1,
c2: c1,
inactiveC1: hexToRgba(c1, 0.45),
inactiveC2: hexToRgba(c1, 0.45),
activeC1: c1,
activeC2: c1,
glow: hexToRgba(c1, 0.35),
badgeBg: hexToRgba(c1, 0.12),
angle: globalAngle,
isCustom: true
};
}
}
return {
...PANE_COLOR_PRESETS.default,
angle: globalAngle,
isCustom: false
};
}
function triggerHaptic(duration = 25) {
if (App.hapticFeedback === false || localStorage.getItem('cd_haptic_feedback') === 'false') {
return;
}
if (typeof navigator !== 'undefined' && navigator.vibrate) {
try {
navigator.vibrate(duration);
} catch (_) {}
}
}
if (App.token) {
try {
document.cookie = `cd_token=${encodeURIComponent(App.token)}; path=/; SameSite=Lax`;
} catch (e) {}
}
function getBasename(path) {
if (!path) return '';
const clean = String(path).replace(/[\\/]+$/, '');
return clean.split(/[\\/]/).filter(Boolean).pop() || clean;
}
function getParentDirectory(path) {
if (!path) return null;
const str = String(path);
// Archive protocol: archive:///path/to/archive.zip#sub/folder
if (str.startsWith('archive://')) {
const [arch, sub] = str.replace('archive://', '').split('#');
if (sub && (sub.includes('/') || sub.includes('\\'))) {
const subParts = sub.split(/[\\/]/).filter(Boolean);
subParts.pop();
return subParts.length > 0 ? `archive://${arch}#${subParts.join('/')}` : `archive://${arch}#`;
} else if (sub) {
return `archive://${arch}#`;
}
const cleanArch = arch.replace(/[\\/]+$/, '');
const archParts = cleanArch.split(/[\\/]/).filter(Boolean);
archParts.pop();
return archParts.length === 0 ? '/' : (arch.includes('\\') ? archParts.join('\\') : '/' + archParts.join('/'));
}
// Vault protocol: vault:///path/to/vault.sec#sub/folder
if (str.startsWith('vault://')) {
const [vaultFile, sub] = str.replace('vault://', '').split('#');
if (sub && (sub.includes('/') || sub.includes('\\'))) {
const subParts = sub.split(/[\\/]/).filter(Boolean);
subParts.pop();
return subParts.length > 0 ? `vault://${vaultFile}#${subParts.join('/')}` : `vault://${vaultFile}#`;
}
return `vault://${vaultFile}#`;
}
// Remote URI: sftp://, smb://, webdav://, proton://, nfs://
const protoMatch = str.match(/^([a-zA-Z0-9_-]+):\/\/(.*)$/);
if (protoMatch) {
const proto = protoMatch[1].toLowerCase();
const rest = protoMatch[2];
const parts = rest.split('/').filter(Boolean);
const minParts = proto === 'smb' ? 2 : 1;
if (parts.length > minParts) {
parts.pop();
return `${proto}://${parts.join('/')}`;
}
return `${proto}://${parts.join('/')}`;
}
// Strip \\?\ prefix or leading slash before Windows drive letter if present
let cleanPath = str;
if (cleanPath.startsWith('\\\\?\\UNC\\')) {
cleanPath = '\\\\' + cleanPath.slice(8);
} else if (cleanPath.startsWith('\\\\?\\')) {
cleanPath = cleanPath.slice(4);
} else if (cleanPath.match(/^\/[a-zA-Z]:/)) {
cleanPath = cleanPath.slice(1);
}
// Windows drive path e.g. C:\Users\Bolt, C:/Users/Bolt, C:\, C:/, C:
const winDriveMatch = cleanPath.match(/^([a-zA-Z]:)[\\/]?(.*)$/);
if (winDriveMatch) {
const drive = winDriveMatch[1].toUpperCase();
const rest = winDriveMatch[2];
const sep = str.includes('/') ? '/' : '\\';
const parts = rest.split(/[\\/]/).filter(Boolean);
if (parts.length <= 1) {
return `${drive}${sep}`;
}
parts.pop();
return `${drive}${sep}${parts.join(sep)}`;
}
// Windows UNC path e.g. \\server\share\subfolder or //server/share/subfolder
const uncMatch = cleanPath.match(/^([\\\/]{2}[^\\\/]+[\\\/][^\\\/]+)[\\/]?(.*)$/);
if (uncMatch) {
const root = uncMatch[1];
const rest = uncMatch[2];
const sep = str.includes('/') ? '/' : '\\';
const parts = rest.split(/[\\/]/).filter(Boolean);
if (parts.length <= 1) {
return root;
}
parts.pop();
return `${root}${sep}${parts.join(sep)}`;
}
// Standard POSIX / Unix path
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length <= 1) return '/';
parts.pop();
return '/' + parts.join('/');
}
function formatBytes(bytes) {
if (bytes === 0 || !bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
const formatFileSize = formatBytes;
// ---------------- 🛡️ UNIVERSAL SHA-256 ENGINE (Hardware Web Crypto + Zero-Dependency JS Fallback) ----------------
function sha256Sync(bytes) {
const uint8 = bytes instanceof Uint8Array ? bytes : (bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : new Uint8Array(bytes.buffer || bytes));
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
let H0 = 0x6a09e667, H1 = 0xbb67ae85, H2 = 0x3c6ef372, H3 = 0xa54ff53a;
let H4 = 0x510e527f, H5 = 0x9b05688c, H6 = 0x1f83d9ab, H7 = 0x5be0cd19;
const l = uint8.length;
const bitLen = l * 8;
const padLen = (((l + 8) >> 6) + 1) << 6;
const padded = new Uint8Array(padLen);
padded.set(uint8);
padded[l] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(padLen - 4, bitLen >>> 0);
view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000));
const W = new Uint32Array(64);
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
for (let i = 0; i < padLen; i += 64) {
for (let t = 0; t < 16; t++) {
W[t] = view.getUint32(i + t * 4);
}
for (let t = 16; t < 64; t++) {
const s0 = rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3);
const s1 = rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10);
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0;
}
let a = H0, b = H1, c = H2, d = H3, e = H4, f = H5, g = H6, h = H7;
for (let t = 0; t < 64; t++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ ((~e) & g);
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
H0 = (H0 + a) >>> 0;
H1 = (H1 + b) >>> 0;
H2 = (H2 + c) >>> 0;
H3 = (H3 + d) >>> 0;
H4 = (H4 + e) >>> 0;
H5 = (H5 + f) >>> 0;
H6 = (H6 + g) >>> 0;
H7 = (H7 + h) >>> 0;
}
const toHex = (n) => n.toString(16).padStart(8, '0');
return (toHex(H0) + toHex(H1) + toHex(H2) + toHex(H3) + toHex(H4) + toHex(H5) + toHex(H6) + toHex(H7)).toLowerCase();
}
async function computeSha256Hex(blobOrBuffer) {
if (!blobOrBuffer) return null;
try {
if (window.crypto && window.crypto.subtle && typeof window.crypto.subtle.digest === 'function') {
const buffer = blobOrBuffer instanceof ArrayBuffer ? blobOrBuffer : (blobOrBuffer instanceof Blob ? await blobOrBuffer.arrayBuffer() : (blobOrBuffer.buffer || blobOrBuffer));
const hashBuf = await window.crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuf));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
} catch (_) {}
try {
let uint8;
if (blobOrBuffer instanceof Blob) {
const ab = await blobOrBuffer.arrayBuffer();
uint8 = new Uint8Array(ab);
} else if (blobOrBuffer instanceof ArrayBuffer) {
uint8 = new Uint8Array(blobOrBuffer);
} else if (blobOrBuffer instanceof Uint8Array) {
uint8 = blobOrBuffer;
} else {
uint8 = new Uint8Array(blobOrBuffer);
}
return sha256Sync(uint8);
} catch (err) {
console.error('SHA-256 fallback computation error:', err);
return null;
}
}
function getUserDefaultHomeDir() {
if (App.user && App.user.home_dir && App.user.home_dir.trim() !== '' && App.user.home_dir !== '/') {
return App.user.home_dir.trim();
}
if (App.systemStatus && App.systemStatus.home_dir && App.systemStatus.home_dir !== '/') {
return App.systemStatus.home_dir.trim();
}
return '~';
}
// ---------------- ⚡ DIRECTORY FAST CACHE (0ms Optimistic Rendering) ----------------
const _dirCacheMemMap = new Map();
const MAX_DIR_CACHE_ENTRIES = 40;
function getDirCacheKey(nodeId, path) {
const normNode = (nodeId && nodeId.trim() !== '') ? nodeId : 'local';
const normPath = (path && path.trim() !== '') ? path : '/';
return `cd_dircache_${normNode}_${normPath}`;
}
function getDirectoryCache(nodeId, path) {
if (!path) return null;
const key = getDirCacheKey(nodeId, path);
if (_dirCacheMemMap.has(key)) {
return _dirCacheMemMap.get(key);
}
try {
const raw = sessionStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.entries)) {
_dirCacheMemMap.set(key, parsed);
return parsed;
}
}
} catch (_) {}
return null;
}
function setDirectoryCache(nodeId, path, data) {
if (!path || !data || !Array.isArray(data.entries)) return;
const key = getDirCacheKey(nodeId, path);
const cacheObj = {
current_path: data.current_path || path,
parent_path: data.parent_path || null,
entries: data.entries || [],
total_size: data.total_size || 0,
is_truncated: !!data.is_truncated,
max_limit: data.max_limit || 5000,
timestamp: Date.now()
};
_dirCacheMemMap.set(key, cacheObj);
if (_dirCacheMemMap.size > MAX_DIR_CACHE_ENTRIES) {
const firstKey = _dirCacheMemMap.keys().next().value;
_dirCacheMemMap.delete(firstKey);
}
try {
sessionStorage.setItem(key, JSON.stringify(cacheObj));
} catch (_) {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const k = sessionStorage.key(i);
if (k && k.startsWith('cd_dircache_') && k !== key) {
sessionStorage.removeItem(k);
}
}
sessionStorage.setItem(key, JSON.stringify(cacheObj));
} catch (_) {}
}
}
function invalidateDirectoryCache(nodeId, path) {
if (!path) {
_dirCacheMemMap.clear();
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const k = sessionStorage.key(i);
if (k && k.startsWith('cd_dircache_')) sessionStorage.removeItem(k);
}
} catch (_) {}
return;
}
const key = getDirCacheKey(nodeId, path);
_dirCacheMemMap.delete(key);
try {
sessionStorage.removeItem(key);
} catch (_) {}
}
function getStartupSettings() {
const mode = localStorage.getItem('cd_startup_mode') || 'restore';
const remoteFallback = localStorage.getItem('cd_startup_remote_fallback') || 'home_fallback';
const customPaths = [
localStorage.getItem('cd_startup_custom_path_0') || '',
localStorage.getItem('cd_startup_custom_path_1') || '',
localStorage.getItem('cd_startup_custom_path_2') || '',
localStorage.getItem('cd_startup_custom_path_3') || ''
];
return { mode, remoteFallback, customPaths };
}
function handleStartupSettingChange() {
const modeSel = document.getElementById('setting-startup-mode');
const remoteSel = document.getElementById('setting-startup-remote-fallback');
const customWrapper = document.getElementById('wrapper-startup-custom-presets');
if (modeSel) {
const val = modeSel.value;
localStorage.setItem('cd_startup_mode', val);
if (customWrapper) {
customWrapper.style.display = (val === 'custom') ? 'block' : 'none';
}
const rememberChk = document.getElementById('setting-remember-paths');
if (rememberChk) {
rememberChk.checked = (val === 'restore');
localStorage.setItem('setting-remember-paths', (val === 'restore') ? 'true' : 'false');
}
}
if (remoteSel) {
localStorage.setItem('cd_startup_remote_fallback', remoteSel.value);
}
for (let i = 0; i < 4; i++) {
const input = document.getElementById(`setting-startup-custom-path-${i}`);
if (input) {
localStorage.setItem(`cd_startup_custom_path_${i}`, input.value.trim());
}
}
if (typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
}
function captureCurrentPathsForCustomStartup() {
for (let i = 0; i < 4; i++) {
const pane = App.panes[i];
const path = pane ? pane.path : getUserDefaultHomeDir();
const input = document.getElementById(`setting-startup-custom-path-${i}`);
if (input) {
input.value = path;
localStorage.setItem(`cd_startup_custom_path_${i}`, path);
}
}
showToast('Current panel paths saved as custom startup presets', 'success');
if (typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
}
class PaneTabState {
constructor(path, options = {}) {
this.id = options.id || `tab_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
this.path = path || getUserDefaultHomeDir();
this.nodeId = options.nodeId || 'local';
this.history = options.history ? [...options.history] : [this.path];
this.historyIndex = options.historyIndex || 0;
this.selected = options.selected ? new Set(options.selected) : new Set();
this.cursorIndex = options.cursorIndex !== undefined ? options.cursorIndex : 0;
this.anchorIndex = options.anchorIndex !== undefined ? options.anchorIndex : 0;
this.filterText = options.filterText || '';
this.showFilter = !!options.showFilter;
this.sortBy = options.sortBy || 'name';
this.sortAsc = options.sortAsc !== undefined ? options.sortAsc : true;
this.viewMode = options.viewMode || 'details';
this.gridSize = options.gridSize || 'md';
this.isBranchView = !!options.isBranchView;
this.isBranchTruncated = !!options.isBranchTruncated;
this.branchMaxLimit = options.branchMaxLimit || 5000;
this.customTitle = options.customTitle || null;
this.scrollTop = options.scrollTop || 0;
}
}
class PaneState {
constructor(id, initialPath = null) {
this.id = id;
const defaultHome = getUserDefaultHomeDir();
const saved = localStorage.getItem(`cd_pane_path_${id}`);
this.path = (saved && saved !== '/') ? saved : (initialPath || defaultHome);
this.nodeId = localStorage.getItem(`cd_pane_node_${id}`) || 'local';
this.entries = [];
this.selected = new Set();
this.cursorIndex = 0;
this.anchorIndex = 0;
this.filterText = '';
this.showFilter = false;
this.history = [this.path];
this.historyIndex = 0;
this.sortBy = 'name';
this.sortAsc = true;
this.totalSize = 0;
this.protocol = 'local';
this.showHidden = App.showHiddenDefault;
this.viewMode = 'details'; // 'details', 'compact', 'grid'
this.gridSize = 'md';
this.isBranchView = false;
this.isBranchTruncated = false;
this.branchMaxLimit = 5000;
this._abortController = null;
this.isVirtual = false;
this.virtualTitle = '';
this.customName = null;
const savedSyncScroll = localStorage.getItem(`cd_pane_sync_scroll_${id}`);
this.syncScroll = savedSyncScroll !== null ? savedSyncScroll === 'true' : true;
const savedSyncNav = localStorage.getItem(`cd_pane_sync_nav_${id}`);
this.syncNav = savedSyncNav !== null ? savedSyncNav === 'true' : true;
// Multi-Tab Support (Issue #37)
this.tabs = [new PaneTabState(this.path, {
nodeId: this.nodeId,
viewMode: this.viewMode,
gridSize: this.gridSize,
sortBy: this.sortBy,
sortAsc: this.sortAsc
})];
this.activeTabIndex = 0;
}
}
function saveCurrentPaneTabState(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length === 0) return;
const currentTab = pane.tabs[pane.activeTabIndex];
if (!currentTab) return;
const mainView = document.querySelector(`#pane-${paneIndex} .pane-main-view`);
const scrollTop = mainView ? mainView.scrollTop : 0;
currentTab.path = pane.path;
currentTab.nodeId = pane.nodeId || 'local';
currentTab.history = [...(pane.history || [pane.path])];
currentTab.historyIndex = pane.historyIndex || 0;
currentTab.selected = new Set(pane.selected);
currentTab.cursorIndex = pane.cursorIndex;
currentTab.anchorIndex = pane.anchorIndex;
currentTab.filterText = pane.filterText || '';
currentTab.showFilter = !!pane.showFilter;
currentTab.sortBy = pane.sortBy || 'name';
currentTab.sortAsc = pane.sortAsc !== undefined ? pane.sortAsc : true;
currentTab.viewMode = pane.viewMode || 'details';
currentTab.gridSize = pane.gridSize || 'md';
currentTab.isBranchView = !!pane.isBranchView;
currentTab.isBranchTruncated = !!pane.isBranchTruncated;
currentTab.branchMaxLimit = pane.branchMaxLimit || 5000;
currentTab.scrollTop = scrollTop;
}
function restorePaneTabState(paneIndex, tabIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length === 0) return;
if (tabIndex < 0 || tabIndex >= pane.tabs.length) tabIndex = 0;
pane.activeTabIndex = tabIndex;
const tab = pane.tabs[tabIndex];
if (!tab) return;
pane.path = tab.path;
pane.nodeId = tab.nodeId || 'local';
pane.history = tab.history ? [...tab.history] : [tab.path];
pane.historyIndex = tab.historyIndex || 0;
pane.selected = tab.selected ? new Set(tab.selected) : new Set();
pane.cursorIndex = tab.cursorIndex || 0;
pane.anchorIndex = tab.anchorIndex || 0;
pane.filterText = tab.filterText || '';
pane.showFilter = !!tab.showFilter;
pane.sortBy = tab.sortBy || 'name';
pane.sortAsc = tab.sortAsc !== undefined ? tab.sortAsc : true;
pane.viewMode = tab.viewMode || 'details';
pane.gridSize = tab.gridSize || 'md';
pane.isBranchView = !!tab.isBranchView;
pane.isBranchTruncated = !!tab.isBranchTruncated;
pane.branchMaxLimit = tab.branchMaxLimit || 5000;
localStorage.setItem(`cd_pane_path_${paneIndex}`, pane.path);
localStorage.setItem(`cd_pane_node_${paneIndex}`, pane.nodeId);
}
function createPaneTab(paneIndex, targetPath = null, activate = true) {
const pane = App.panes[paneIndex];
if (!pane) return;
if (!pane.tabs) {
pane.tabs = [new PaneTabState(pane.path)];
pane.activeTabIndex = 0;
}
saveCurrentPaneTabState(paneIndex);
const cloneFrom = pane.tabs[pane.activeTabIndex];
const newPath = targetPath || pane.path || getUserDefaultHomeDir();
const newTab = new PaneTabState(newPath, {
nodeId: cloneFrom?.nodeId || pane.nodeId || 'local',
viewMode: cloneFrom?.viewMode || pane.viewMode || 'details',
gridSize: cloneFrom?.gridSize || pane.gridSize || 'md',
sortBy: cloneFrom?.sortBy || pane.sortBy || 'name',
sortAsc: cloneFrom?.sortAsc !== undefined ? cloneFrom.sortAsc : true,
showFilter: false,
filterText: '',
history: [newPath],
historyIndex: 0
});
pane.tabs.push(newTab);
if (activate) {
const newIdx = pane.tabs.length - 1;
restorePaneTabState(paneIndex, newIdx);
renderPaneTabs(paneIndex);
loadPaneDirectory(paneIndex, newPath);
} else {
renderPaneTabs(paneIndex);
}
}
function closePaneTab(paneIndex, tabIndex, event = null) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length <= 1) {
return;
}
if (tabIndex < 0 || tabIndex >= pane.tabs.length) return;
const wasActive = tabIndex === pane.activeTabIndex;
pane.tabs.splice(tabIndex, 1);
if (wasActive) {
const newActiveIndex = Math.min(tabIndex, pane.tabs.length - 1);
restorePaneTabState(paneIndex, newActiveIndex);
renderPaneTabs(paneIndex);
loadPaneDirectory(paneIndex, pane.path);
} else {
if (pane.activeTabIndex > tabIndex) {
pane.activeTabIndex--;
}
renderPaneTabs(paneIndex);
}
}
function switchPaneTab(paneIndex, tabIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs) return;
if (tabIndex < 0 || tabIndex >= pane.tabs.length) return;
if (tabIndex === pane.activeTabIndex) return;
saveCurrentPaneTabState(paneIndex);
restorePaneTabState(paneIndex, tabIndex);
renderPaneTabs(paneIndex);
loadPaneDirectory(paneIndex, pane.path);
}
function nextPaneTab(paneIndex = App.activePaneIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length <= 1) return;
const nextIdx = (pane.activeTabIndex + 1) % pane.tabs.length;
switchPaneTab(paneIndex, nextIdx);
}
function prevPaneTab(paneIndex = App.activePaneIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length <= 1) return;
const prevIdx = (pane.activeTabIndex - 1 + pane.tabs.length) % pane.tabs.length;
switchPaneTab(paneIndex, prevIdx);
}
function duplicatePaneTab(paneIndex, tabIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || !pane.tabs[tabIndex]) return;
saveCurrentPaneTabState(paneIndex);
const src = pane.tabs[tabIndex];
const newTab = new PaneTabState(src.path, {
nodeId: src.nodeId,
viewMode: src.viewMode,
gridSize: src.gridSize,
sortBy: src.sortBy,
sortAsc: src.sortAsc,
isBranchView: src.isBranchView,
history: [...src.history],
historyIndex: src.historyIndex
});
pane.tabs.splice(tabIndex + 1, 0, newTab);
restorePaneTabState(paneIndex, tabIndex + 1);
renderPaneTabs(paneIndex);
loadPaneDirectory(paneIndex, pane.path);
}
function closeOtherTabs(paneIndex, keepIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || pane.tabs.length <= 1) return;
const tabToKeep = pane.tabs[keepIndex];
if (!tabToKeep) return;
pane.tabs = [tabToKeep];
restorePaneTabState(paneIndex, 0);
renderPaneTabs(paneIndex);
loadPaneDirectory(paneIndex, pane.path);
}
function closeTabsToTheRight(paneIndex, fromIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || fromIndex >= pane.tabs.length - 1) return;
saveCurrentPaneTabState(paneIndex);
pane.tabs = pane.tabs.slice(0, fromIndex + 1);
if (pane.activeTabIndex > fromIndex) {
restorePaneTabState(paneIndex, fromIndex);
loadPaneDirectory(paneIndex, pane.path);
}
renderPaneTabs(paneIndex);
}
function openPaneTabContextMenu(e, paneIndex, tabIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
document.getElementById('pane-tab-context-menu')?.remove();
const existingCtx = document.getElementById('context-menu');
if (existingCtx) existingCtx.style.display = 'none';
const pane = App.panes[paneIndex];
if (!pane || !pane.tabs || !pane.tabs[tabIndex]) return;
const tab = pane.tabs[tabIndex];
const tabPath = tab.path || '/';
const menu = document.createElement('div');
menu.id = 'pane-tab-context-menu';
menu.className = 'context-menu active';
menu.style.position = 'fixed';
menu.style.zIndex = '10000';
const hasMultiple = pane.tabs.length > 1;
const hasRightTabs = tabIndex < pane.tabs.length - 1;
menu.innerHTML = `
<div class="context-menu-item" onclick="document.getElementById('pane-tab-context-menu')?.remove(); setActivePane(${paneIndex}); createPaneTab(${paneIndex});">
<i data-lucide="plus" style="width: 14px; height: 14px; margin-right: 8px;"></i>
<span>New Tab</span>
<span class="context-shortcut">Ctrl+T</span>
</div>
<div class="context-menu-item" onclick="document.getElementById('pane-tab-context-menu')?.remove(); duplicatePaneTab(${paneIndex}, ${tabIndex});">
<i data-lucide="copy" style="width: 14px; height: 14px; margin-right: 8px;"></i>
<span>Duplicate Tab</span>
</div>
<div class="context-menu-item" onclick="document.getElementById('pane-tab-context-menu')?.remove(); copyToClipboard('${escapeHtml(tabPath)}'); showToast('Path copied to clipboard');">
<i data-lucide="clipboard" style="width: 14px; height: 14px; margin-right: 8px;"></i>
<span>Copy Tab Path</span>
</div>
<div class="context-menu-sep"></div>
<div class="context-menu-item ${!hasMultiple ? 'disabled' : ''}" onclick="if (${hasMultiple}) { document.getElementById('pane-tab-context-menu')?.remove(); closePaneTab(${paneIndex}, ${tabIndex}); }">
<i data-lucide="x" style="width: 14px; height: 14px; margin-right: 8px; color: #ef4444;"></i>
<span>Close Tab</span>
<span class="context-shortcut">Ctrl+W</span>
</div>
<div class="context-menu-item ${!hasMultiple ? 'disabled' : ''}" onclick="if (${hasMultiple}) { document.getElementById('pane-tab-context-menu')?.remove(); closeOtherTabs(${paneIndex}, ${tabIndex}); }">
<i data-lucide="x-circle" style="width: 14px; height: 14px; margin-right: 8px;"></i>
<span>Close Other Tabs</span>
</div>
<div class="context-menu-item ${!hasRightTabs ? 'disabled' : ''}" onclick="if (${hasRightTabs}) { document.getElementById('pane-tab-context-menu')?.remove(); closeTabsToTheRight(${paneIndex}, ${tabIndex}); }">
<i data-lucide="arrow-right-to-line" style="width: 14px; height: 14px; margin-right: 8px;"></i>
<span>Close Tabs to the Right</span>
</div>
`;
document.body.appendChild(menu);
if (window.lucide) lucide.createIcons();
let posX = e.clientX || 100;
let posY = e.clientY || 100;
const menuRect = menu.getBoundingClientRect();
if (posX + menuRect.width > window.innerWidth) posX = window.innerWidth - menuRect.width - 8;
if (posY + menuRect.height > window.innerHeight) posY = window.innerHeight - menuRect.height - 8;
menu.style.left = `${Math.max(8, posX)}px`;
menu.style.top = `${Math.max(8, posY)}px`;
menu.style.display = 'block';
const closeMenu = (ev) => {
if (!menu.contains(ev.target)) {
menu.remove();
document.removeEventListener('pointerdown', closeMenu);
}
};
setTimeout(() => document.addEventListener('pointerdown', closeMenu), 50);
}
function isPaneTabsEnabled() {
const mode = localStorage.getItem('cd_pane_tabs_mode') || 'pc_only';
if (mode === 'disabled') return false;
if (mode === 'always') return true;
const width = window.innerWidth;
if (mode === 'pc_tablet') {
return width >= 600;
}
// 'pc_only' (> 1024px)
return width > 1024;
}
function updatePaneTabsMode(mode) {
App.paneTabsMode = mode;
localStorage.setItem('cd_pane_tabs_mode', mode);
updateAllPaneTabsVisibility();
queueSaveUserPreferencesToServer();
const label = mode === 'pc_only' ? 'PC & Desktop Only (>1024px)' : mode === 'always' ? 'All Viewports' : mode === 'pc_tablet' ? 'PC & Tablet (>=600px)' : 'Disabled';
showToast(`Multi-tab panels mode set to: ${label}`, 'success');
}
function updateAllPaneTabsVisibility() {
const visible = isPaneTabsEnabled();
const visibleCount = getVisiblePaneCount();
for (let i = 0; i < visibleCount; i++) {
const pane = App.panes[i];
const strip = document.getElementById(`pane-tab-strip-${i}`);
if (strip) {
if (!visible || (pane && pane.dockedTool)) {
strip.style.display = 'none';
} else {
renderPaneTabs(i);
}
}
}
}
function renderPaneTabs(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
const stripEl = document.getElementById(`pane-tab-strip-${paneIndex}`);
if (!stripEl) return;
if (pane.dockedTool || !isPaneTabsEnabled()) {
stripEl.style.display = 'none';
return;
}
stripEl.style.display = 'flex';
if (!pane.tabs || pane.tabs.length === 0) {
pane.tabs = [new PaneTabState(pane.path)];
pane.activeTabIndex = 0;
}
if (pane.activeTabIndex >= pane.tabs.length) {
pane.activeTabIndex = pane.tabs.length - 1;
}
if (pane.activeTabIndex < 0) {
pane.activeTabIndex = 0;
}
if (pane.tabs[pane.activeTabIndex]) {
pane.tabs[pane.activeTabIndex].path = pane.path;
pane.tabs[pane.activeTabIndex].nodeId = pane.nodeId || 'local';
}
let tabsHtml = `<div class="pane-tabs-scroll" id="pane-tabs-scroll-${paneIndex}">`;
pane.tabs.forEach((tab, tIdx) => {
const isActive = tIdx === pane.activeTabIndex;
const path = tab.path || '/';
let title = tab.customTitle;
let icon = 'folder';
if (!title) {
if (path.startsWith('archive://')) {
const clean = path.replace(/^archive:\/\//, '');
const hashIdx = clean.indexOf('#');
if (hashIdx !== -1) {
const arcName = clean.substring(0, hashIdx).split('/').filter(Boolean).pop();
const innerPath = clean.substring(hashIdx + 1).replace(/^\/+/, '');
title = innerPath ? `${arcName} #${innerPath.split('/').filter(Boolean).pop()}` : arcName;
} else {
title = clean.split('/').filter(Boolean).pop() || clean;
}
icon = 'archive';
} else if (path === '/' || path === '') {
title = '/';
} else if (path === '~') {
title = '~';
} else {
const segments = path.split('/').filter(Boolean);
title = segments[segments.length - 1] || path;
}
}
if (tab.nodeId && tab.nodeId !== 'local') {
icon = 'server';
}
const showClose = pane.tabs.length > 1;
const tooltip = `${tab.nodeId && tab.nodeId !== 'local' ? `[${tab.nodeId}] ` : ''}${path}`;
tabsHtml += `
<div class="pane-tab-item ${isActive ? 'active' : ''}"
id="pane-${paneIndex}-tab-${tIdx}"
onclick="setActivePane(${paneIndex}); switchPaneTab(${paneIndex}, ${tIdx});"
onauxclick="if (event.button === 1) { event.preventDefault(); closePaneTab(${paneIndex}, ${tIdx}, event); }"
oncontextmenu="event.preventDefault(); openPaneTabContextMenu(event, ${paneIndex}, ${tIdx});"
title="${escapeHtml(tooltip)}">
<span class="pane-tab-icon">
<i data-lucide="${icon}" style="width: 12px; height: 12px;"></i>
</span>
<span class="pane-tab-title">${escapeHtml(title)}</span>
${showClose ? `
<button class="pane-tab-close" onclick="event.stopPropagation(); closePaneTab(${paneIndex}, ${tIdx}, event);" title="Close Tab (Ctrl+W / Middle-click)">✕</button>
` : ''}
</div>
`;
});
tabsHtml += '</div>';
tabsHtml += `
<button class="btn-new-tab" onclick="event.stopPropagation(); setActivePane(${paneIndex}); createPaneTab(${paneIndex});" title="New Tab in this Pane (Ctrl+T)">
<i data-lucide="plus"></i>
</button>
`;
stripEl.innerHTML = tabsHtml;
if (window.lucide) lucide.createIcons();
const activeTabEl = document.getElementById(`pane-${paneIndex}-tab-${pane.activeTabIndex}`);
if (activeTabEl) {
try {
activeTabEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
} catch (_) {}
}
}
let _prefSyncDebounceTimer = null;
function getAllUserPreferences() {
return {
// 1. Panes & Viewport Layouts
pane_names: App.panes.map(p => p.customName || null),
pane_colors: getPaneColors(),
pane_nodes: App.panes.map(p => p.nodeId || 'local'),
default_layout: App.layout,
layout_pc: localStorage.getItem('cd_layout_pc') || localStorage.getItem('cd_layout') || 'layout-dual-vertical',
layout_tablet: localStorage.getItem('cd_layout_tablet') || 'layout-dual-vertical',
layout_phone: localStorage.getItem('cd_layout_phone') || 'layout-dual-vertical',
pane_start_paths: App.panes.map(p => p.path || null),
pane_viewmodes: App.panes.map(p => p.viewMode || 'details'),
pane_gridsizes: App.panes.map(p => p.gridSize || 'md'),
pane_trees: App.panes.map(p => !!p.showTree),
// 2. Appearance & Theme
theme: localStorage.getItem('cd_theme') || 'amber-charcoal',
custom_themes: JSON.parse(localStorage.getItem('cd_custom_themes') || '[]'),
font_size: App.fontSize || 13,
border_width: localStorage.getItem('cd_border_width') || '1px',
ring_style: localStorage.getItem('cd_ring_style') || 'subtle',
border_angle: localStorage.getItem('cd_border_angle') || '45deg',
icon_theme: localStorage.getItem('cd_icon_theme') || 'default',
global_folder_icon: localStorage.getItem('cd_global_folder_icon') || '',
custom_file_icons: JSON.parse(localStorage.getItem('cd_custom_file_icons') || '{}'),
// 3. Hostname Badge & Window Title
show_hostname_badge: localStorage.getItem('cd_show_hostname_badge') !== 'false',
custom_hostname: localStorage.getItem('cd_custom_hostname') || '',
custom_app_title: localStorage.getItem('cd_custom_app_title') || '',
hostname_color: localStorage.getItem('cd_hostname_color') || 'amber',
hostname_custom_text: localStorage.getItem('cd_hostname_custom_text') || '#f59e0b',
hostname_custom_bg: localStorage.getItem('cd_hostname_custom_bg') || 'rgba(245, 158, 11, 0.15)',
hostname_custom_border: localStorage.getItem('cd_hostname_custom_border') || 'rgba(245, 158, 11, 0.35)',
hostname_custom_glow: localStorage.getItem('cd_hostname_custom_glow') || 'rgba(245, 158, 11, 0.4)',
hostname_style: localStorage.getItem('cd_hostname_style') || 'subtle',
hostname_icon: localStorage.getItem('cd_hostname_icon') || 'server',
hostname_size: localStorage.getItem('cd_hostname_size') || 'md',
// 4. Column Configuration & Display Options
col_widths: ColumnConfig?.widths || {},
col_visibility: ColumnConfig?.visibility || {},
col_separators: localStorage.getItem('cd_col_separators') !== '0',
sticky_col_headers: localStorage.getItem('cd_sticky_col_headers') !== '0',
show_dir_tag: localStorage.getItem('cd_show_dir_tag') === '1',
compact_dates: localStorage.getItem('cd_compact_dates') !== '0',
file_list_multiline: localStorage.getItem('cd_file_list_multiline') === '1',
// 5. General UI Toggles & Behavior
show_hidden: App.showHiddenDefault,
show_fkeys: App.showFKeyBar !== false && localStorage.getItem('cd_show_fkeys') !== 'false',
show_parent_dir: App.showParentDir,
show_global_refresh: localStorage.getItem('cd_show_global_refresh') === 'true',
dblclick_up: App.dblclickUpDir,
auto_open_tasks: App.autoOpenTasks,
task_verbosity: App.taskVerbosity,
trash_enabled: App.trashEnabled,
custom_trash_dir: App.customTrashDir,
default_copy_action: getDefaultCopyAction(),
haptic_feedback: App.hapticFeedback !== false && localStorage.getItem('cd_haptic_feedback') !== 'false',
// 6. Drag & Drop & Rearrangement & Max Panes & Tabs
pane_reorder_enabled: App.paneReorderEnabled !== false,
activate_on_hover: App.activateOnHover === true,
phone_tablet_max_panes: App.phoneTabletMaxPanes || 2,
pane_tabs_mode: localStorage.getItem('cd_pane_tabs_mode') || 'pc_only',
mobile_bottom_bar_mode: App.mobileBottomBarMode || localStorage.getItem('cd_mobile_bottom_bar_mode') || 'icons_text',
sync_scroll: App.syncScroll === true || localStorage.getItem('cd_sync_scroll') === 'true',
sync_nav: App.syncNav === true || localStorage.getItem('cd_sync_nav') === 'true',
pane_sync_scroll: App.panes.map(p => p.syncScroll !== false),
pane_sync_nav: App.panes.map(p => p.syncNav !== false),
dnd_default_action: App.dndDefaultAction,
dnd_prompt_mode: App.dndPromptMode,
dnd_paranoid_prompt: App.dndParanoidPrompt,
// 7. Quick Destinations & Templates & Tools Menu
quick_destinations: App.quickDestinations,
custom_templates: JSON.parse(localStorage.getItem('cd_custom_templates') || '[]'),
custom_tools_menu: JSON.parse(localStorage.getItem('cd_custom_tools_menu') || 'null'),
fleet_nodes: typeof loadFleetNodes === 'function' ? loadFleetNodes() : [],
// 8. EditorDog & DiffDog & Viewer
editor_theme: localStorage.getItem('cd_editor_theme') || 'default',
editor_fontsize: parseInt(localStorage.getItem('cd_editor_fontsize') || '13', 10),
editor_wordwrap: localStorage.getItem('cd_editor_wordwrap') !== 'false',
editor_minimap: localStorage.getItem('cd_editor_minimap') !== 'false',
diff_ignore_whitespace: localStorage.getItem('cd_diff_ignore_whitespace') === 'true',
diff_view_mode: localStorage.getItem('cd_diff_view_mode') || 'split',
// 9. Resizable Pane Splits
pane_splits: getPaneSplits()
};
}
function applyAllUserPreferences(prefs) {
if (!prefs || typeof prefs !== 'object') return;
// 1. Panes Custom Names
if (Array.isArray(prefs.pane_names)) {
prefs.pane_names.forEach((name, i) => {
if (App.panes[i]) App.panes[i].customName = name || null;
});
localStorage.setItem('cd_pane_custom_names', JSON.stringify(App.panes.map(p => p.customName || null)));
}
// 2. Pane Colors
if (prefs.pane_colors !== undefined && prefs.pane_colors !== null && typeof prefs.pane_colors === 'object') {
localStorage.setItem('cd_pane_colors', JSON.stringify(prefs.pane_colors));
}
// 2b. Pane Fleet Nodes
if (Array.isArray(prefs.pane_nodes)) {
prefs.pane_nodes.forEach((nodeId, i) => {
if (App.panes[i] && nodeId) {
App.panes[i].nodeId = nodeId;
localStorage.setItem(`cd_pane_node_${i}`, nodeId);
}
});
}
// 2c. Fleet Registry
if (Array.isArray(prefs.fleet_nodes) && prefs.fleet_nodes.length > 0) {
const localNodes = typeof loadFleetNodes === 'function' ? loadFleetNodes() : [];
if (!localNodes || localNodes.length === 0) {
localStorage.setItem('cd_fleet_nodes', JSON.stringify(prefs.fleet_nodes));
} else {
const merged = [...localNodes];
prefs.fleet_nodes.forEach(srvNode => {
const idx = merged.findIndex(l => l.id === srvNode.id || (srvNode.endpoint_url && l.endpoint_url === srvNode.endpoint_url));
if (idx >= 0) {
if ((srvNode.updated_at || 0) > (merged[idx].updated_at || 0)) {
merged[idx] = { ...merged[idx], ...srvNode };
}
} else {
merged.push(srvNode);
}
});
localStorage.setItem('cd_fleet_nodes', JSON.stringify(merged));
}
if (typeof renderFleetSwitcherDropdown === 'function') {
renderFleetSwitcherDropdown();
}
const managerModal = document.getElementById('fleet-manager-modal');
if (managerModal && managerModal.classList.contains('active') && typeof renderFleetManagerList === 'function') {
renderFleetManagerList();
}
}
// 3. Viewport-Decoupled Default Layouts (on fresh session load)
if (prefs.layout_pc) localStorage.setItem('cd_layout_pc', prefs.layout_pc);
if (prefs.layout_tablet) localStorage.setItem('cd_layout_tablet', prefs.layout_tablet);
if (prefs.layout_phone) localStorage.setItem('cd_layout_phone', prefs.layout_phone);
if (prefs.default_layout && !localStorage.getItem('cd_layout_pc')) {
localStorage.setItem('cd_layout_pc', prefs.default_layout);
}
const currentVp = getViewportType();
const targetLayout = getLayoutForViewport(currentVp);
if (!sessionStorage.getItem('cd_session_layout_initialized') || ((currentVp === 'phone' || currentVp === 'tablet') && App.layout !== targetLayout)) {
sessionStorage.setItem('cd_session_layout_initialized', '1');
if (App.layout !== targetLayout) {
App.layout = targetLayout;
updateActiveLayoutUI(targetLayout);
if (typeof renderAllPanes === 'function') {
renderAllPanes();
}
}
}
// 4. Starting Paths (on fresh session load)
if (Array.isArray(prefs.pane_start_paths) && prefs.pane_start_paths.length > 0) {
prefs.pane_start_paths.forEach((startPath, i) => {
if (App.panes[i] && startPath && typeof startPath === 'string' && startPath.trim()) {
const sessionKey = `cd_session_path_initialized_${i}`;
if (!sessionStorage.getItem(sessionKey)) {
sessionStorage.setItem(sessionKey, '1');
App.panes[i].path = startPath.trim();
App.panes[i].history = [startPath.trim()];
App.panes[i].historyIndex = 0;
localStorage.setItem(`cd_pane_path_${i}`, startPath.trim());
}
}
});
}
// 5. Pane Viewmodes, Grid Sizes & Trees
if (Array.isArray(prefs.pane_viewmodes)) {
prefs.pane_viewmodes.forEach((vm, i) => {
if (App.panes[i] && vm) {
App.panes[i].viewMode = vm;
localStorage.setItem(`cd_pane_viewmode_${i}`, vm);
}
});
}
if (Array.isArray(prefs.pane_gridsizes)) {
prefs.pane_gridsizes.forEach((gs, i) => {
if (App.panes[i] && gs) {
App.panes[i].gridSize = gs;
localStorage.setItem(`cd_pane_gridsize_${i}`, gs);
}
});
}
if (Array.isArray(prefs.pane_trees)) {
prefs.pane_trees.forEach((tr, i) => {
if (App.panes[i] !== undefined) {
App.panes[i].showTree = !!tr;
localStorage.setItem(`cd_pane_tree_${i}`, tr ? '1' : '0');
}
});
}
// 6. Theme & Custom Themes
if (Array.isArray(prefs.custom_themes) && prefs.custom_themes.length > 0) {
localStorage.setItem('cd_custom_themes', JSON.stringify(prefs.custom_themes));
}
if (prefs.theme && typeof prefs.theme === 'string') {
localStorage.setItem('cd_theme', prefs.theme);
if (typeof applyTheme === 'function') {
applyTheme(prefs.theme, true);
}
}
// 7. Base Font Size
if (prefs.font_size && typeof prefs.font_size === 'number') {
App.fontSize = prefs.font_size;
localStorage.setItem('cd_font_size', prefs.font_size);
if (typeof applyFontSize === 'function') {
applyFontSize(prefs.font_size);
}
}
// 8. Border Width, Active Ring Style & Gradient Angle
if (prefs.border_width || prefs.ring_style || prefs.border_angle) {
if (prefs.border_width) localStorage.setItem('cd_border_width', prefs.border_width);
if (prefs.ring_style) localStorage.setItem('cd_ring_style', prefs.ring_style);
if (prefs.border_angle) localStorage.setItem('cd_border_angle', prefs.border_angle);
if (typeof applyBorderSettings === 'function') {
applyBorderSettings(prefs.border_width, prefs.ring_style, prefs.border_angle, true);
}
}
// 9. Hostname Badge Customizer & Window Title
if (prefs.show_hostname_badge !== undefined) {
localStorage.setItem('cd_show_hostname_badge', prefs.show_hostname_badge ? 'true' : 'false');
}
if (prefs.custom_hostname !== undefined) {
if (prefs.custom_hostname) localStorage.setItem('cd_custom_hostname', prefs.custom_hostname);
else localStorage.removeItem('cd_custom_hostname');
}
if (prefs.custom_app_title !== undefined) {
if (prefs.custom_app_title) localStorage.setItem('cd_custom_app_title', prefs.custom_app_title);
else localStorage.removeItem('cd_custom_app_title');
if (typeof updateAppDocumentTitle === 'function') updateAppDocumentTitle();
}
if (prefs.hostname_color) localStorage.setItem('cd_hostname_color', prefs.hostname_color);
if (prefs.hostname_custom_text) localStorage.setItem('cd_hostname_custom_text', prefs.hostname_custom_text);
if (prefs.hostname_custom_bg) localStorage.setItem('cd_hostname_custom_bg', prefs.hostname_custom_bg);
if (prefs.hostname_custom_border) localStorage.setItem('cd_hostname_custom_border', prefs.hostname_custom_border);
if (prefs.hostname_custom_glow) localStorage.setItem('cd_hostname_custom_glow', prefs.hostname_custom_glow);
if (prefs.hostname_style) localStorage.setItem('cd_hostname_style', prefs.hostname_style);
if (prefs.hostname_icon) localStorage.setItem('cd_hostname_icon', prefs.hostname_icon);
if (prefs.hostname_size) localStorage.setItem('cd_hostname_size', prefs.hostname_size);
if (typeof updateHostnameBadge === 'function') {
updateHostnameBadge();
}
// 10. Table Column Widths & Visibility
if (prefs.col_widths && typeof prefs.col_widths === 'object' && ColumnConfig) {
ColumnConfig.widths = prefs.col_widths;
localStorage.setItem('cd_col_widths', JSON.stringify(prefs.col_widths));
}
if (prefs.col_visibility && typeof prefs.col_visibility === 'object' && ColumnConfig) {
ColumnConfig.visibility = Object.assign(ColumnConfig.visibility, prefs.col_visibility);
localStorage.setItem('cd_col_visibility', JSON.stringify(ColumnConfig.visibility));
}
if (typeof applyAllColumnWidths === 'function') {
applyAllColumnWidths();
}
if (prefs.col_separators !== undefined) {
localStorage.setItem('cd_col_separators', prefs.col_separators ? '1' : '0');
const sepEl = document.getElementById('setting-col-separators');
if (sepEl) sepEl.checked = !!prefs.col_separators;
}
if (prefs.sticky_col_headers !== undefined) {
localStorage.setItem('cd_sticky_col_headers', prefs.sticky_col_headers ? '1' : '0');
applyStickyColHeaders();
}
if (prefs.show_dir_tag !== undefined) {
localStorage.setItem('cd_show_dir_tag', prefs.show_dir_tag ? '1' : '0');
const dirEl = document.getElementById('setting-show-dir-tag');
if (dirEl) dirEl.checked = !!prefs.show_dir_tag;
}
if (prefs.compact_dates !== undefined) {
localStorage.setItem('cd_compact_dates', prefs.compact_dates ? '1' : '0');
const dateEl = document.getElementById('setting-compact-dates');
if (dateEl) dateEl.checked = !!prefs.compact_dates;
}
if (prefs.file_list_multiline !== undefined) {
localStorage.setItem('cd_file_list_multiline', prefs.file_list_multiline ? '1' : '0');
const multiEl = document.getElementById('setting-file-list-multiline');
if (multiEl) multiEl.checked = !!prefs.file_list_multiline;
}
// 11. General UI Toggles & Safety
if (prefs.show_hidden !== undefined) {
App.showHiddenDefault = !!prefs.show_hidden;
localStorage.setItem('cd_show_hidden', prefs.show_hidden ? 'true' : 'false');
const dotCb = document.getElementById('setting-show-hidden');
if (dotCb) dotCb.checked = App.showHiddenDefault;
}
if (prefs.show_fkeys !== undefined) {
App.showFKeyBar = !!prefs.show_fkeys;
localStorage.setItem('cd_show_fkeys', prefs.show_fkeys ? 'true' : 'false');
if (typeof applyFKeyBarState === 'function') applyFKeyBarState();
}
if (prefs.show_parent_dir !== undefined) {
App.showParentDir = !!prefs.show_parent_dir;
localStorage.setItem('cd_show_parent_dir', prefs.show_parent_dir ? 'true' : 'false');
const pCb = document.getElementById('setting-show-parent-dir');
if (pCb) pCb.checked = App.showParentDir;
}
if (prefs.show_global_refresh !== undefined) {
localStorage.setItem('cd_show_global_refresh', prefs.show_global_refresh ? 'true' : 'false');
const grCb = document.getElementById('setting-show-global-refresh');
if (grCb) grCb.checked = !!prefs.show_global_refresh;
if (typeof updateGlobalRefreshButtonVisibility === 'function') updateGlobalRefreshButtonVisibility();
}
if (prefs.dblclick_up !== undefined) {
App.dblclickUpDir = !!prefs.dblclick_up;
localStorage.setItem('cd_dblclick_up', prefs.dblclick_up ? 'true' : 'false');
const dbCb = document.getElementById('setting-dblclick-up');
if (dbCb) dbCb.checked = App.dblclickUpDir;
}
if (prefs.auto_open_tasks !== undefined) {
App.autoOpenTasks = !!prefs.auto_open_tasks;
localStorage.setItem('cd_auto_open_tasks', prefs.auto_open_tasks ? 'true' : 'false');
const aCb = document.getElementById('setting-auto-open-tasks');
if (aCb) aCb.checked = App.autoOpenTasks;
}
if (prefs.task_verbosity !== undefined) {
App.taskVerbosity = prefs.task_verbosity;
localStorage.setItem('cd_task_verbosity', prefs.task_verbosity);
}
if (prefs.trash_enabled !== undefined) {
App.trashEnabled = !!prefs.trash_enabled;
localStorage.setItem('cd_trash_enabled', prefs.trash_enabled ? 'true' : 'false');
}
if (prefs.default_copy_action !== undefined) {
localStorage.setItem('brum_default_copy_action', prefs.default_copy_action);
const copyActionSel = document.getElementById('setting-default-copy-action');
if (copyActionSel) copyActionSel.value = prefs.default_copy_action;
}
if (prefs.custom_trash_dir !== undefined) {
App.customTrashDir = prefs.custom_trash_dir;
localStorage.setItem('cd_custom_trash_dir', prefs.custom_trash_dir);
}
if (prefs.haptic_feedback !== undefined) {
App.hapticFeedback = !!prefs.haptic_feedback;
localStorage.setItem('cd_haptic_feedback', prefs.haptic_feedback ? 'true' : 'false');
const hapticCb = document.getElementById('setting-haptic-feedback');
if (hapticCb) hapticCb.checked = App.hapticFeedback;
}
// 12. Drag & Drop & Panel Rearrangement & Max Panes
if (prefs.pane_reorder_enabled !== undefined) {
App.paneReorderEnabled = !!prefs.pane_reorder_enabled;
localStorage.setItem('cd_pane_reorder_enabled', prefs.pane_reorder_enabled ? 'true' : 'false');
const reorderCb = document.getElementById('setting-pane-reorder');
if (reorderCb) reorderCb.checked = App.paneReorderEnabled;
}
if (prefs.activate_on_hover !== undefined) {
App.activateOnHover = !!prefs.activate_on_hover;
localStorage.setItem('cd_activate_on_hover', prefs.activate_on_hover ? '1' : '0');
const hoverCb = document.getElementById('setting-activate-on-hover');
if (hoverCb) hoverCb.checked = App.activateOnHover;
}
if (prefs.phone_tablet_max_panes !== undefined) {
App.phoneTabletMaxPanes = parseInt(prefs.phone_tablet_max_panes, 10) || 2;
localStorage.setItem('cd_phone_tablet_max_panes', App.phoneTabletMaxPanes.toString());
const maxSel = document.getElementById('setting-phone-tablet-max-panes');
if (maxSel) maxSel.value = App.phoneTabletMaxPanes.toString();
}
if (prefs.pane_tabs_mode !== undefined) {
App.paneTabsMode = prefs.pane_tabs_mode;
localStorage.setItem('cd_pane_tabs_mode', prefs.pane_tabs_mode);
const tabModeSel = document.getElementById('setting-pane-tabs-mode');
if (tabModeSel) tabModeSel.value = prefs.pane_tabs_mode;
if (typeof updateAllPaneTabsVisibility === 'function') updateAllPaneTabsVisibility();
}
if (prefs.mobile_bottom_bar_mode !== undefined) {
App.mobileBottomBarMode = prefs.mobile_bottom_bar_mode;
localStorage.setItem('cd_mobile_bottom_bar_mode', prefs.mobile_bottom_bar_mode);
applyMobileBottomBarMode(prefs.mobile_bottom_bar_mode);
}
if (prefs.sync_scroll !== undefined) {
App.syncScroll = !!prefs.sync_scroll;
localStorage.setItem('cd_sync_scroll', prefs.sync_scroll ? 'true' : 'false');
applySyncScrollState();
}
if (prefs.sync_nav !== undefined) {
App.syncNav = !!prefs.sync_nav;
localStorage.setItem('cd_sync_nav', prefs.sync_nav ? 'true' : 'false');
applySyncNavState();
}
if (Array.isArray(prefs.pane_sync_scroll)) {
prefs.pane_sync_scroll.forEach((val, i) => {
if (App.panes[i]) {
App.panes[i].syncScroll = val !== false;
localStorage.setItem(`cd_pane_sync_scroll_${i}`, val !== false ? 'true' : 'false');
}
});
}
if (Array.isArray(prefs.pane_sync_nav)) {
prefs.pane_sync_nav.forEach((val, i) => {
if (App.panes[i]) {
App.panes[i].syncNav = val !== false;
localStorage.setItem(`cd_pane_sync_nav_${i}`, val !== false ? 'true' : 'false');
}
});
}
if (prefs.dnd_default_action) {
App.dndDefaultAction = prefs.dnd_default_action;
localStorage.setItem('cd_dnd_default_action', prefs.dnd_default_action);
const dndAct = document.getElementById('setting-dnd-default-action');
if (dndAct) dndAct.value = prefs.dnd_default_action;
}
if (prefs.dnd_prompt_mode) {
App.dndPromptMode = prefs.dnd_prompt_mode;
localStorage.setItem('cd_dnd_prompt_mode', prefs.dnd_prompt_mode);
}
if (prefs.dnd_paranoid_prompt !== undefined) {
App.dndParanoidPrompt = !!prefs.dnd_paranoid_prompt;
localStorage.setItem('cd_dnd_paranoid_prompt', prefs.dnd_paranoid_prompt ? 'true' : 'false');
}
// 13. Quick Destinations & Templates & Tools Menu
if (Array.isArray(prefs.quick_destinations) && prefs.quick_destinations.length > 0) {
App.quickDestinations = prefs.quick_destinations;
localStorage.setItem('cd_quick_destinations', JSON.stringify(prefs.quick_destinations));
}
if (Array.isArray(prefs.custom_templates) && prefs.custom_templates.length > 0) {
localStorage.setItem('cd_custom_templates', JSON.stringify(prefs.custom_templates));
}
if (Array.isArray(prefs.custom_tools_menu)) {
localStorage.setItem('cd_custom_tools_menu', JSON.stringify(prefs.custom_tools_menu));
if (typeof renderToolsMenu === 'function') renderToolsMenu();
}
// 14. Icon Themes & Custom Overrides
if (prefs.icon_theme) {
App.iconTheme = prefs.icon_theme;
localStorage.setItem('cd_icon_theme', prefs.icon_theme);
}
if (prefs.global_folder_icon !== undefined) {
const isLegacyImg = typeof prefs.global_folder_icon === 'string' && (prefs.global_folder_icon.includes('.webp') || prefs.global_folder_icon.includes('assets/'));
const cleanFolderIcon = isLegacyImg ? '' : prefs.global_folder_icon;
App.globalFolderIcon = cleanFolderIcon;
if (cleanFolderIcon) localStorage.setItem('cd_global_folder_icon', cleanFolderIcon);
else localStorage.removeItem('cd_global_folder_icon');
}
if (prefs.custom_file_icons && typeof prefs.custom_file_icons === 'object') {
App.customFileIcons = prefs.custom_file_icons;
localStorage.setItem('cd_custom_file_icons', JSON.stringify(prefs.custom_file_icons));
}
// 15. EditorDog & DiffDog & Viewer Preferences
if (prefs.editor_theme) localStorage.setItem('cd_editor_theme', prefs.editor_theme);
if (prefs.editor_fontsize) localStorage.setItem('cd_editor_fontsize', prefs.editor_fontsize);
if (prefs.editor_wordwrap !== undefined) localStorage.setItem('cd_editor_wordwrap', prefs.editor_wordwrap ? 'true' : 'false');
if (prefs.editor_minimap !== undefined) localStorage.setItem('cd_editor_minimap', prefs.editor_minimap ? 'true' : 'false');
if (prefs.diff_ignore_whitespace !== undefined) localStorage.setItem('cd_diff_ignore_whitespace', prefs.diff_ignore_whitespace ? 'true' : 'false');
if (prefs.diff_view_mode) localStorage.setItem('cd_diff_view_mode', prefs.diff_view_mode);
// 16. Resizable Pane Splits
if (prefs.pane_splits && typeof prefs.pane_splits === 'object') {
const mergedSplits = Object.assign({}, DEFAULT_PANE_SPLITS, prefs.pane_splits);
savePaneSplits(mergedSplits);
applyPaneSplits(mergedSplits);
}
// Synchronize UI
updatePaneTitles();
applyPaneColors();
if (typeof renderAllPanes === 'function') {
renderAllPanes();
}
}
async function loadUserPreferencesFromServer() {
try {
const headers = {};
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch('/api/user/preferences', { headers });
if (!resp.ok) return;
const prefs = await resp.json();
if (!prefs || typeof prefs !== 'object' || Object.keys(prefs).length === 0) return;
applyAllUserPreferences(prefs);
} catch (e) {
console.warn('Could not load user preferences from server:', e);
}
}
function queueSaveUserPreferencesToServer() {
clearTimeout(_prefSyncDebounceTimer);
_prefSyncDebounceTimer = setTimeout(async () => {
try {
const prefs = getAllUserPreferences();
const headers = { 'Content-Type': 'application/json' };
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch('/api/user/preferences', {
method: 'POST',
headers,
body: JSON.stringify(prefs)
});
if (res.ok) {
const badge = document.getElementById('cross-device-sync-badge');
if (badge) {
badge.textContent = 'Cloud Synced';
badge.style.color = '#34d399';
}
}
} catch (e) {
console.warn('Failed to save user preferences to server:', e);
}
}, 200);
}
async function saveCurrentWorkspaceAsDefault() {
try {
const prefs = getAllUserPreferences();
const headers = { 'Content-Type': 'application/json' };
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch('/api/user/preferences', {
method: 'POST',
headers,
body: JSON.stringify(prefs)
});
if (res.ok) {
showToast('All profile settings, themes, columns & workspaces saved to server profile!', 'success');
const badge = document.getElementById('cross-device-sync-badge');
if (badge) {
badge.textContent = 'Profile Synced';
badge.style.color = '#34d399';
}
} else {
showToast('Failed to save preferences to server.', 'error');
}
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
}
}
async function resetServerPreferences() {
try {
const headers = {};
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch('/api/user/preferences', {
method: 'DELETE',
headers
});
if (res.ok) {
localStorage.removeItem('cd_pane_custom_names');
localStorage.removeItem('cd_pane_colors');
localStorage.removeItem('cd_pane_splits');
applyPaneSplits(DEFAULT_PANE_SPLITS);
App.panes.forEach((p) => { p.customName = null; });
updatePaneTitles();
applyPaneColors();
showToast('Cross-device profile settings reset on server.', 'info');
const badge = document.getElementById('cross-device-sync-badge');
if (badge) {
badge.textContent = 'Reset';
badge.style.color = 'var(--text-muted)';
}
} else {
showToast('Failed to reset server preferences.', 'error');
}
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
}
}
function loadPaneCustomNames() {
try {
const saved = JSON.parse(localStorage.getItem('cd_pane_custom_names') || '[]');
if (Array.isArray(saved)) {
saved.forEach((name, i) => {
if (App.panes[i]) App.panes[i].customName = name;
});
}
} catch (e) {}
}
function savePaneCustomNames() {
const names = App.panes.map(p => p.customName || null);
localStorage.setItem('cd_pane_custom_names', JSON.stringify(names));
queueSaveUserPreferencesToServer();
}
async function promptRenamePane(index) {
const pane = App.panes[index];
if (!pane) return;
const newName = await showPromptDialog({
title: `Rename Pane ${index + 1}`,
subtitle: 'Customize pane tab name (leave empty for default)',
message: 'Enter pane name:',
defaultValue: pane.customName || '',
placeholder: `e.g. Workspace, Downloads, ${index + 1}`,
confirmText: 'Rename'
});
if (newName !== null) {
pane.customName = newName.trim() || null;
savePaneCustomNames();
updatePaneTitles();
showToast(pane.customName ? `Pane ${index + 1} renamed to "${pane.customName}"` : `Pane ${index + 1} name reset to "${index + 1}"`, 'info');
}
}
function updatePaneTitles() {
const colors = getPaneColors();
const globalAngle = localStorage.getItem('cd_border_angle') || '45deg';
App.panes.forEach((pane, pIdx) => {
const displayName = pane.customName || `${pIdx + 1}`;
const badgeText = document.getElementById(`pane-badge-text-${pIdx}`);
if (badgeText) {
badgeText.textContent = displayName;
}
const ghost = document.getElementById(`pane-ghost-${pIdx}`);
if (ghost) {
ghost.textContent = displayName;
const colorConf = colors[pIdx] || 'default';
const resolved = typeof resolvePaneColorConfig === 'function' ? resolvePaneColorConfig(colorConf, globalAngle) : { activeC1: '#f59e0b', id: 'default' };
if (resolved.id !== 'default') {
ghost.style.color = resolved.activeC1;
} else {
ghost.style.color = 'var(--accent)';
}
ghost.classList.toggle('pane-ghost-long', displayName.length > 2);
}
});
}
// Initialization
function bootApp() {
try {
initPanes();
applyPaneSplits();
setupEventListeners();
setupKeyboardNavigation();
setupHistoryNavigation();
applyFKeyBarState();
applyMobileBottomBarMode();
applySyncScrollState();
applySyncNavState();
applyFontSize(App.fontSize);
applyBorderSettings();
applyAllColumnWidths();
applyStickyColHeaders();
renderToolsMenu();
const urlTheme = new URLSearchParams(window.location.search).get('theme');
applyTheme(urlTheme || localStorage.getItem('cd_theme') || 'amber-charcoal');
updateHostnameBadge();
fetchAppVersion();
startTasksPolling();
initInactivityTracker();
initFolderTree();
initTreeResizer();
setupTabBarMouseWheel();
if (localStorage.getItem('cd_is_locked') === 'true') {
try {
const cached = JSON.parse(localStorage.getItem('cd_user_info') || 'null');
const localAvatar = localStorage.getItem('cd_local_avatar');
const localNick = localStorage.getItem('cd_local_nickname');
const lastUser = localStorage.getItem('cd_last_username') || '';
const userTextEl = document.getElementById('lock-username-text');
const hostSuffixEl = document.getElementById('lock-hostname-suffix');
const userLabel = document.getElementById('lock-username-label');
const avatarEl = document.getElementById('lock-avatar-thumb');
const uname = cached?.nickname || localNick || cached?.username || lastUser || 'Brum User';
const avatar = cached?.avatar_url || localAvatar || '👤';
const cfg = getHostnameBadgeSettings();
const hostStr = cfg.hostname || 'localhost';
if (userTextEl) {
userTextEl.textContent = uname;
} else if (userLabel) {
userLabel.innerHTML = `<span id="lock-username-text">${typeof escapeHtml === 'function' ? escapeHtml(uname) : uname}</span><span id="lock-hostname-suffix" class="lock-hostname-suffix">@${typeof escapeHtml === 'function' ? escapeHtml(hostStr) : hostStr}</span>`;
}
if (hostSuffixEl) {
hostSuffixEl.textContent = `@${hostStr}`;
}
if (avatarEl) renderAvatarElement(avatarEl, avatar);
} catch (_) {}
}
// ⚡ Frame-0 Instant Optimistic Pane Mounting:
// If a session token, standalone mode, or auth-disabled is present and session is not locked,
// immediately render pane frames and cached directories on frame 0!
const token = App.token || localStorage.getItem('cd_token');
const isStandalone = isStandaloneMode() || localStorage.getItem('cd_standalone_mode') === 'true';
const authDisabled = localStorage.getItem('cd_auth_disabled') === 'true';
const isLocked = localStorage.getItem('cd_is_locked') === 'true';
if ((token || isStandalone || authDisabled) && !isLocked) {
document.documentElement.classList.remove('auth-pending-login', 'auth-verifying');
document.documentElement.classList.add('auth-ready');
try {
renderAllPanes(true);
} catch (optErr) {
console.warn('Optimistic frame-0 render warning:', optErr);
}
}
} catch (initErr) {
console.error('Core UI initialization error:', initErr);
} finally {
checkAuthAndLoad();
}
}
// Global HTML5 Drag and Drop prevention to ensure no blocking cursor occurs across DOM elements
['dragover', 'dragenter'].forEach(evt => {
window.addEventListener(evt, (e) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
}
}, true);
document.addEventListener(evt, (e) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
}
}, true);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootApp);
} else {
bootApp();
}
function initPanes() {
const defaultCount = 4;
const defaultHome = getUserDefaultHomeDir();
const startupSettings = getStartupSettings();
for (let i = 0; i < defaultCount; i++) {
let startupNode = localStorage.getItem(`cd_pane_node_${i}`) || 'local';
let startupPath = defaultHome;
// Remote host boot fallback check (Reset to local home to avoid network connection delay)
if (startupSettings.remoteFallback === 'home_fallback' && startupNode !== 'local') {
startupNode = 'local';
localStorage.setItem(`cd_pane_node_${i}`, 'local');
}
if (startupSettings.mode === 'home') {
startupPath = defaultHome;
} else if (startupSettings.mode === 'root') {
startupPath = '/';
} else if (startupSettings.mode === 'custom') {
startupPath = startupSettings.customPaths[i] || defaultHome;
} else { // 'restore'
const remember = localStorage.getItem('setting-remember-paths') !== 'false';
const savedPath = localStorage.getItem(`cd_pane_path_${i}`);
if (remember && savedPath && savedPath !== '/') {
if (startupSettings.remoteFallback === 'home_fallback' && (savedPath.startsWith('ssh://') || savedPath.startsWith('sftp://') || savedPath.startsWith('smb://') || savedPath.startsWith('client://'))) {
startupPath = defaultHome;
} else {
startupPath = savedPath;
}
} else {
startupPath = defaultHome;
}
}
const p = new PaneState(i, startupPath);
p.nodeId = startupNode;
p.path = startupPath;
p.history = [startupPath];
p.historyIndex = 0;
p.viewMode = localStorage.getItem(`cd_pane_viewmode_${i}`) || 'details';
p.gridSize = localStorage.getItem(`cd_pane_gridsize_${i}`) || 'md';
p.showTree = localStorage.getItem(`cd_pane_tree_${i}`) === '1';
p.dockedTool = localStorage.getItem(`cd_pane_docked_${i}`) || null;
p.syncScroll = localStorage.getItem(`cd_pane_sync_scroll_${i}`) !== 'false';
p.syncNav = localStorage.getItem(`cd_pane_sync_nav_${i}`) !== 'false';
// Seed pane from directory fast cache immediately on frame 0!
const cachedListing = getDirectoryCache(p.nodeId, p.path);
if (cachedListing && cachedListing.entries && Array.isArray(cachedListing.entries)) {
p.entries = cachedListing.entries;
p.parentPath = cachedListing.parent_path || null;
p.totalSize = cachedListing.total_size || 0;
p.isBranchTruncated = !!cachedListing.is_truncated;
p.branchMaxLimit = cachedListing.max_limit || 5000;
}
App.panes.push(p);
}
loadPaneCustomNames();
}
function applyUserHomeToPanes(force = false) {
const home = getUserDefaultHomeDir();
const startupSettings = getStartupSettings();
if (startupSettings.mode === 'home') {
App.panes.forEach((pane, idx) => {
pane.path = home;
pane.history = [home];
pane.historyIndex = 0;
});
return;
}
if (startupSettings.mode === 'root') {
App.panes.forEach((pane, idx) => {
pane.path = '/';
pane.history = ['/'];
pane.historyIndex = 0;
});
return;
}
if (startupSettings.mode === 'custom') {
App.panes.forEach((pane, idx) => {
const customPath = startupSettings.customPaths[idx] || home;
pane.path = customPath;
pane.history = [customPath];
pane.historyIndex = 0;
});
return;
}
if (home && home !== '/') {
App.panes.forEach((pane, idx) => {
const saved = localStorage.getItem(`cd_pane_path_${idx}`);
if (force || !saved || saved === '/' || pane.path === '/' || pane.path === '~') {
pane.path = home;
pane.history = [home];
pane.historyIndex = 0;
localStorage.setItem(`cd_pane_path_${idx}`, home);
}
});
}
}
function isWindowsHost() {
if (App.systemStatus?.os) {
return App.systemStatus.os.toLowerCase() === 'windows';
}
const homePath = (typeof getUserDefaultHomeDir === 'function') ? getUserDefaultHomeDir() : '';
if (/^[a-zA-Z]:/.test(homePath) || (homePath && homePath.includes('\\'))) {
return true;
}
if (typeof navigator !== 'undefined' && navigator.userAgent && navigator.userAgent.includes('Windows') && isStandaloneMode()) {
return true;
}
return false;
}
function getTrashDisplayName(withBin = true) {
const isWinNative = isWindowsHost() && App.windowsNativeOps;
if (isWinNative) {
return 'Recycle Bin';
}
return withBin ? 'Trash Bin' : 'Trash';
}
function getTrashActionName() {
const isWinNative = isWindowsHost() && App.windowsNativeOps;
return isWinNative ? 'Move to Recycle Bin' : 'Move to Trash';
}
function updatePlatformLabels() {
const isWin = isWindowsHost();
const isWinNative = isWin && App.windowsNativeOps;
// Settings labels
const adminTrashTitle = document.getElementById('admin-setting-trash-title');
if (adminTrashTitle) {
adminTrashTitle.textContent = isWinNative ? 'Safe Recycle Bin' : 'Safe Trash Bin';
}
const adminTrashHint = document.getElementById('admin-setting-trash-hint');
if (adminTrashHint) {
adminTrashHint.textContent = isWinNative
? 'Move deleted items to Windows Recycle Bin ($Recycle.Bin) instead of permanent deletion.'
: 'Move deleted items to Brum Internal Trash (.local/share/Trash) instead of permanent deletion.';
}
const customTrashInput = document.getElementById('setting-custom-trash-dir');
if (customTrashInput) {
customTrashInput.placeholder = isWinNative
? 'Leave empty for OS default (Windows Recycle Bin)'
: 'Leave empty for OS default (~/.local/share/Trash)';
}
// Duplicate cleaner action select option
const dupTrashOption = document.getElementById('dup-action-trash-option');
if (dupTrashOption) {
dupTrashOption.textContent = isWinNative
? 'Move to Recycle Bin ($Recycle.Bin)'
: 'Move to Trash (.local/share/Trash)';
}
}
function updateStandaloneUI() {
const isStandalone = isStandaloneMode();
document.body.classList.toggle('standalone-mode', isStandalone);
document.querySelectorAll('.desktop-only-setting, .standalone-only').forEach(el => {
el.style.display = isStandalone ? '' : 'none';
});
document.querySelectorAll('.web-only-setting, .server-only-setting').forEach(el => {
el.style.display = isStandalone ? 'none' : '';
});
const isWin = isWindowsHost();
document.body.classList.toggle('windows-host', isWin);
document.querySelectorAll('.windows-only-setting, .windows-only').forEach(el => {
el.style.display = isWin ? '' : 'none';
});
document.querySelectorAll('.non-windows-only, .posix-only-setting').forEach(el => {
el.style.display = isWin ? 'none' : '';
});
updatePlatformLabels();
updateLogoutOrExitButton();
}
async function checkAuthAndLoad() {
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
const ssoSuccess = urlParams.get('sso_success');
const urlError = urlParams.get('error');
if (urlToken) {
App.token = urlToken;
localStorage.setItem('cd_token', urlToken);
try {
document.cookie = `cd_token=${encodeURIComponent(urlToken)}; path=/; SameSite=Lax`;
} catch (e) {}
window.history.replaceState({}, document.title, window.location.pathname);
if (ssoSuccess) {
setTimeout(() => showToast('Successfully signed in via SSO', 'success'), 300);
}
} else if (urlError) {
window.history.replaceState({}, document.title, window.location.pathname);
setTimeout(() => {
const errEl = document.getElementById('login-error');
if (errEl) {
errEl.style.display = 'block';
errEl.textContent = `SSO Sign-in Failed: ${decodeURIComponent(urlError)}`;
}
showToast(`SSO Sign-in Failed: ${decodeURIComponent(urlError)}`, 'error');
}, 200);
}
const token = App.token || localStorage.getItem('cd_token');
const isLocked = localStorage.getItem('cd_is_locked') === 'true';
try {
const isTauri = window.__TAURI__ !== undefined || window.__TAURI_INTERNALS__ !== undefined || window.__WRY__ !== undefined;
const meHeaders = token ? { 'Authorization': `Bearer ${token}` } : {};
// Parallelize system status and token verification
const [sysResp, meResp] = await Promise.all([
fetch('/api/system/status').catch(() => null),
(token || isTauri) ? fetch('/api/auth/me', { headers: meHeaders }).catch(() => null) : Promise.resolve(null)
]);
let sysData = null;
if (sysResp && sysResp.ok) {
sysData = await sysResp.json();
App.systemStatus = sysData;
if (sysData.hostname) localStorage.setItem('cd_cached_hostname', sysData.hostname);
if (sysData.custom_hostname) localStorage.setItem('cd_custom_hostname', sysData.custom_hostname);
if (sysData.version) applyAppVersion(sysData.version);
updateHostnameBadge();
App.isStandalone = !!sysData.standalone;
updateStandaloneUI();
}
const isStandalone = !!sysData?.standalone || isTauri;
const authDisabled = sysData?.auth_enabled === false;
// 1. Authenticated via token or local standalone/auth-disabled user:
if (meResp && meResp.ok) {
App.user = await meResp.json();
const localAvatar = localStorage.getItem('cd_local_avatar');
const localNick = localStorage.getItem('cd_local_nickname');
if (localAvatar && !App.user.avatar_url) App.user.avatar_url = localAvatar;
if (localNick && !App.user.nickname) App.user.nickname = localNick;
updateHeaderProfile(App.user);
try {
localStorage.setItem('cd_user_info', JSON.stringify({
username: App.user.username,
nickname: App.user.nickname || localNick,
avatar_url: App.user.avatar_url || localAvatar
}));
localStorage.setItem('cd_last_username', App.user.username);
} catch (_) {}
hideModal('login-modal');
if (isLocked) {
document.documentElement.classList.remove('auth-pending-login', 'auth-verifying');
document.documentElement.classList.add('auth-pending-lock');
lockSession();
return;
}
// INSTANT UI DISPLAY: Remove all auth shields & render panes immediately!
document.documentElement.classList.remove('auth-pending-login', 'auth-pending-lock', 'auth-verifying');
document.documentElement.classList.add('auth-ready');
try {
applyUserHomeToPanes();
renderAllPanes();
restoreTerminalState();
} catch (renderErr) {
console.error('UI pane rendering error post-auth:', renderErr);
}
// Non-blocking background data hydration
Promise.allSettled([
loadConfig(),
loadSystemUsersGroups(),
loadAdminSecuritySettings(),
loadAllFileTags(),
loadUserPreferencesFromServer(),
loadInstalledChewToys(false)
]);
return;
}
// 2. Standalone / Auth-disabled Fallback:
if (isStandalone || authDisabled) {
const localMe = await fetch('/api/auth/me').catch(() => null);
if (localMe && localMe.ok) {
App.user = await localMe.json();
} else {
App.user = {
id: 1,
username: sysData?.current_user || 'user',
nickname: sysData?.current_user || 'user',
role: 'admin',
home_dir: sysData?.home_dir || '/',
is_pam: false,
can_install_plugins: true,
allowed_roots: '["*"]',
allowed_services: '["*"]'
};
}
updateHeaderProfile(App.user);
hideModal('login-modal');
if (isLocked) {
document.documentElement.classList.remove('auth-pending-login', 'auth-verifying');
document.documentElement.classList.add('auth-pending-lock');
lockSession();
return;
}
document.documentElement.classList.remove('auth-pending-login', 'auth-pending-lock', 'auth-verifying');
document.documentElement.classList.add('auth-ready');
try {
applyUserHomeToPanes();
renderAllPanes();
restoreTerminalState();
} catch (renderErr) {
console.error('UI pane rendering error post-auth fallback:', renderErr);
}
Promise.allSettled([
loadConfig(),
loadSystemUsersGroups(),
loadAdminSecuritySettings(),
loadAllFileTags(),
loadUserPreferencesFromServer(),
loadInstalledChewToys(false)
]);
return;
}
} catch (e) {
console.warn('Auth check error:', e);
}
// If we had an invalid token, clear it from storage
if (token) {
localStorage.removeItem('cd_token');
App.token = null;
}
// Not authenticated: show login screen
loadOidcConfig();
document.documentElement.classList.remove('auth-pending-lock', 'auth-verifying', 'auth-ready');
document.documentElement.classList.add('auth-pending-login');
showModal('login-modal');
setTimeout(() => {
document.getElementById('login-username')?.focus();
}, 100);
}
async function fetchAppVersion() {
try {
const res = await fetch('/api/system/status');
if (res.ok) {
const data = await res.json();
App.systemStatus = data;
if (data.hostname) {
localStorage.setItem('cd_cached_hostname', data.hostname);
}
if (data.custom_hostname) {
localStorage.setItem('cd_custom_hostname', data.custom_hostname);
}
if (data.version) applyAppVersion(data.version);
updateHostnameBadge();
}
} catch (_) {}
}
function applyAppVersion(ver) {
if (!ver) return;
App.version = ver;
try {
localStorage.setItem('cd_cached_version', ver);
} catch (_) {}
document.querySelectorAll('.login-version-badge').forEach(el => el.textContent = `v${ver}`);
document.querySelectorAll('.lock-version-badge, #lock-corner-version-badge').forEach(el => el.textContent = `Brum v${ver}`);
document.querySelectorAll('.login-app-version, #login-app-version').forEach(el => el.textContent = ver);
document.querySelectorAll('#about-version-badge, .about-version-badge').forEach(el => el.textContent = `v${ver} (Desktop & Web)`);
const syncBadge = document.getElementById('sync-version-badge');
if (syncBadge) syncBadge.textContent = `v${ver}`;
updateAppDocumentTitle();
}
function getAppBaseTitle() {
const customTitle = localStorage.getItem('cd_custom_app_title')
|| App.config?.ui?.window_title
|| App.systemStatus?.window_title;
return (customTitle && customTitle.trim().length > 0)
? customTitle.trim()
: 'Brum - Multi-Pane Web Environment';
}
function updateAppDocumentTitle() {
const baseTitle = getAppBaseTitle();
// 1. Audio Player Now-Playing
if (typeof sounddogState !== 'undefined' && sounddogState.isPlaying && sounddogState.queue && sounddogState.queue[sounddogState.currentIndex]) {
const track = sounddogState.queue[sounddogState.currentIndex];
const trackTitle = track.title || track.name || 'Audio Track';
const artist = track.artist ? ` - ${track.artist}` : '';
document.title = `▶ ${trackTitle}${artist} | ${baseTitle}`;
return;
}
// 2. Media Player (Video) Now-Playing
if (typeof mediaplayerState !== 'undefined' && mediaplayerState.isPlaying && mediaplayerState.playlist && mediaplayerState.playlist[mediaplayerState.currentIndex]) {
const media = mediaplayerState.playlist[mediaplayerState.currentIndex];
const mediaTitle = media.title || media.name || 'Media';
document.title = `▶ ${mediaTitle} | ${baseTitle}`;
return;
}
document.title = baseTitle;
}
function handleWindowTitleSettingChange() {
const input = document.getElementById('setting-custom-window-title');
if (!input) return;
const val = input.value.trim();
if (val) {
localStorage.setItem('cd_custom_app_title', val);
} else {
localStorage.removeItem('cd_custom_app_title');
}
updateAppDocumentTitle();
if (typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
}
function resetWindowTitleSetting() {
const input = document.getElementById('setting-custom-window-title');
if (input) input.value = '';
localStorage.removeItem('cd_custom_app_title');
updateAppDocumentTitle();
if (typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
showToast('Window title reset to default', 'info');
}
function getHostnameBadgeSettings() {
const savedShow = localStorage.getItem('cd_show_hostname_badge');
const show = savedShow !== null
? (savedShow === 'true')
: (App.systemStatus?.show_hostname_badge !== false && App.config?.ui?.show_hostname_badge !== false);
const savedCustom = localStorage.getItem('cd_custom_hostname');
const customLabel = (savedCustom && savedCustom.trim())
|| App.config?.ui?.hostname_badge
|| App.systemStatus?.custom_hostname;
const color = localStorage.getItem('cd_hostname_color')
|| App.config?.ui?.hostname_color
|| App.systemStatus?.hostname_color
|| 'slate';
const customText = localStorage.getItem('cd_hostname_custom_text') || '#94a3b8';
const customBg = localStorage.getItem('cd_hostname_custom_bg') || 'rgba(148, 163, 184, 0.12)';
const customBorder = localStorage.getItem('cd_hostname_custom_border') || 'rgba(148, 163, 184, 0.3)';
const customGlow = localStorage.getItem('cd_hostname_custom_glow') || 'rgba(245, 158, 11, 0.35)';
const style = localStorage.getItem('cd_hostname_style')
|| App.config?.ui?.hostname_style
|| App.systemStatus?.hostname_style
|| 'text';
const icon = localStorage.getItem('cd_hostname_icon')
|| App.config?.ui?.hostname_icon
|| App.systemStatus?.hostname_icon
|| 'none';
const size = localStorage.getItem('cd_hostname_size')
|| App.config?.ui?.hostname_size
|| App.systemStatus?.hostname_size
|| 'md';
const cachedHost = localStorage.getItem('cd_cached_hostname');
const rawHostname = (customLabel && customLabel.trim().length > 0)
? customLabel.trim()
: (App.systemStatus?.hostname || cachedHost || 'localhost');
// Format as @hostname by default for text prompts
const hostname = (style === 'text' && !rawHostname.startsWith('@'))
? `@${rawHostname}`
: rawHostname;
return { show, hostname, rawHostname, customLabel, color, customText, customBg, customBorder, customGlow, style, icon, size };
}
function getLocalHostDisplayName() {
const hostSettings = typeof getHostnameBadgeSettings === 'function' ? getHostnameBadgeSettings() : null;
if (hostSettings && hostSettings.rawHostname && hostSettings.rawHostname.trim()) {
return hostSettings.rawHostname.trim();
}
return App.systemStatus?.hostname || localStorage.getItem('cd_cached_hostname') || 'localhost';
}
function renderHostnameBadgeElement(badgeEl, textEl, cfg) {
if (!badgeEl) return;
if (!cfg.show) {
badgeEl.style.display = 'none';
return;
}
if (textEl) textEl.textContent = cfg.hostname;
badgeEl.style.display = 'inline-flex';
badgeEl.className = `header-hostname-badge color-${cfg.color} style-${cfg.style} size-${cfg.size}`;
if (cfg.color === 'custom') {
badgeEl.style.setProperty('--custom-badge-color', cfg.customText);
badgeEl.style.setProperty('--custom-badge-bg', cfg.customBg);
badgeEl.style.setProperty('--custom-badge-border', cfg.customBorder);
badgeEl.style.setProperty('--custom-badge-glow', cfg.customGlow);
} else {
badgeEl.style.removeProperty('--custom-badge-color');
badgeEl.style.removeProperty('--custom-badge-bg');
badgeEl.style.removeProperty('--custom-badge-border');
badgeEl.style.removeProperty('--custom-badge-glow');
}
// Update Icon
let iconEl = badgeEl.querySelector('#header-hostname-icon') || badgeEl.querySelector('i:first-child:not(.header-hostname-chevron), svg:first-child:not(.header-hostname-chevron)');
if (cfg.icon === 'none') {
if (iconEl) iconEl.style.display = 'none';
} else {
if (!iconEl) {
iconEl = document.createElement('i');
if (textEl) badgeEl.insertBefore(iconEl, textEl);
else badgeEl.appendChild(iconEl);
}
iconEl.style.display = 'inline-block';
iconEl.setAttribute('data-lucide', cfg.icon);
if (badgeEl.id === 'header-hostname-badge') {
iconEl.id = 'header-hostname-icon';
}
}
// Ensure dropdown chevron exists at end
let chevron = badgeEl.querySelector('.header-hostname-chevron') || badgeEl.querySelector('i:last-child, svg:last-child');
if (!chevron || chevron === iconEl) {
chevron = document.createElement('i');
chevron.className = 'header-hostname-chevron';
chevron.setAttribute('data-lucide', 'chevron-down');
badgeEl.appendChild(chevron);
} else if (!chevron.classList.contains('header-hostname-chevron')) {
chevron.classList.add('header-hostname-chevron');
}
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons({ root: badgeEl });
}
}
function updateHostnameBadge() {
const badge = document.getElementById('header-hostname-badge');
const textEl = document.getElementById('header-hostname-text');
const logoArea = document.querySelector('.logo-area');
const cfg = getHostnameBadgeSettings();
if (logoArea) {
logoArea.classList.toggle('has-hostname-badge', Boolean(cfg.show));
}
// Header Badge
renderHostnameBadgeElement(badge, textEl, cfg);
// Login Screen Hostname Title
const loginTitleEl = document.getElementById('login-hostname-title');
if (loginTitleEl) {
loginTitleEl.textContent = cfg.rawHostname || 'Brum';
}
// Lock Screen Hostname Suffix (Ensure resilient span preservation)
let lockSuffix = document.getElementById('lock-hostname-suffix');
const userLabel = document.getElementById('lock-username-label');
if (!lockSuffix && userLabel) {
const userTextEl = document.getElementById('lock-username-text');
const uname = userTextEl?.textContent || userLabel.textContent || 'Brum User';
userLabel.innerHTML = `<span id="lock-username-text">${typeof escapeHtml === 'function' ? escapeHtml(uname) : uname}</span><span id="lock-hostname-suffix" class="lock-hostname-suffix">@${typeof escapeHtml === 'function' ? escapeHtml(cfg.rawHostname || 'localhost') : (cfg.rawHostname || 'localhost')}</span>`;
} else if (lockSuffix) {
lockSuffix.textContent = `@${cfg.rawHostname || 'localhost'}`;
}
if (badge && cfg.show) {
const os = App.systemStatus?.os || 'linux';
const arch = App.systemStatus?.arch || 'x86_64';
const user = App.user?.username || App.systemStatus?.current_user || 'user';
badge.title = `Host: ${cfg.rawHostname} (${os}/${arch})\nUser: ${user}\nClick to switch server / manage fleet`;
}
updateHostnameSettingsPreview();
}
function updateHostnameSettingsPreview() {
const previewBadge = document.getElementById('hostname-settings-preview-badge');
const previewText = document.getElementById('hostname-preview-text');
const customColorRow = document.getElementById('hostname-custom-color-picker-row');
if (!previewBadge) return;
const cfg = getHostnameBadgeSettings();
renderHostnameBadgeElement(previewBadge, previewText, cfg);
if (cfg.color === 'custom') {
if (customColorRow) customColorRow.style.display = 'flex';
} else {
if (customColorRow) customColorRow.style.display = 'none';
}
}
function handleHostnameSettingChange() {
const labelInput = document.getElementById('setting-custom-hostname-label');
const colorSelect = document.getElementById('setting-hostname-color');
const styleSelect = document.getElementById('setting-hostname-style');
const iconSelect = document.getElementById('setting-hostname-icon');
const sizeSelect = document.getElementById('setting-hostname-size');
const customTextInput = document.getElementById('setting-hostname-custom-text');
const customBgInput = document.getElementById('setting-hostname-custom-bg');
const customBorderInput = document.getElementById('setting-hostname-custom-border');
const customGlowInput = document.getElementById('setting-hostname-custom-glow');
if (labelInput) {
const val = labelInput.value.trim();
if (val) localStorage.setItem('cd_custom_hostname', val);
else localStorage.removeItem('cd_custom_hostname');
}
if (colorSelect) localStorage.setItem('cd_hostname_color', colorSelect.value);
if (styleSelect) localStorage.setItem('cd_hostname_style', styleSelect.value);
if (iconSelect) localStorage.setItem('cd_hostname_icon', iconSelect.value);
if (sizeSelect) localStorage.setItem('cd_hostname_size', sizeSelect.value);
if (customTextInput && customTextInput.value) localStorage.setItem('cd_hostname_custom_text', customTextInput.value);
if (customBgInput && customBgInput.value) localStorage.setItem('cd_hostname_custom_bg', customBgInput.value);
if (customBorderInput && customBorderInput.value) localStorage.setItem('cd_hostname_custom_border', customBorderInput.value);
if (customGlowInput && customGlowInput.value) localStorage.setItem('cd_hostname_custom_glow', customGlowInput.value);
updateHostnameBadge();
queueSaveUserPreferencesToServer();
}
function applyCustomBadgePreset(text, bg, border, glow) {
const customTextInput = document.getElementById('setting-hostname-custom-text');
const customBgInput = document.getElementById('setting-hostname-custom-bg');
const customBorderInput = document.getElementById('setting-hostname-custom-border');
const customGlowInput = document.getElementById('setting-hostname-custom-glow');
const textPicker = document.getElementById('setting-hostname-custom-text-picker');
const borderPicker = document.getElementById('setting-hostname-custom-border-picker');
const glowPicker = document.getElementById('setting-hostname-custom-glow-picker');
if (customTextInput) customTextInput.value = text;
if (customBgInput) customBgInput.value = bg;
if (customBorderInput) customBorderInput.value = border;
if (customGlowInput) customGlowInput.value = glow;
if (textPicker && text.startsWith('#')) textPicker.value = text;
if (borderPicker && border.startsWith('#')) borderPicker.value = border;
if (glowPicker && glow.startsWith('#')) glowPicker.value = glow;
const colorSelect = document.getElementById('setting-hostname-color');
if (colorSelect) colorSelect.value = 'custom';
handleHostnameSettingChange();
}
function copyHostnameBadge() {
const cfg = getHostnameBadgeSettings();
const os = App.systemStatus?.os || 'linux';
const arch = App.systemStatus?.arch || 'x86_64';
const user = App.user?.username || App.systemStatus?.current_user || 'user';
const textToCopy = `${user}@${cfg.hostname} [${os}/${arch}]`;
copyTextToClipboard(textToCopy, `Host info copied to clipboard: ${textToCopy}`);
}
function toggleHostnameBadgeSetting(enabled) {
localStorage.setItem('cd_show_hostname_badge', enabled);
const wrapper = document.getElementById('wrapper-custom-hostname');
if (wrapper) wrapper.style.display = enabled ? 'flex' : 'none';
updateHostnameBadge();
showToast(enabled ? 'Hostname badge enabled in top header' : 'Hostname badge hidden', 'info');
}
function saveCustomHostnameSetting(val) {
handleHostnameSettingChange();
}
async function loadConfig() {
try {
const res = await fetch('/api/config');
if (res.ok) {
App.config = await res.json();
App.paranoidMode = App.config.paranoid.enabled;
updateParanoidBadge();
if (App.config.version) applyAppVersion(App.config.version);
// Merge client-side custom themes saved in browser localStorage
try {
const localCustoms = JSON.parse(localStorage.getItem('cd_custom_themes') || '[]');
if (Array.isArray(localCustoms) && localCustoms.length > 0) {
if (!App.config.themes) App.config.themes = { themes: [] };
if (!Array.isArray(App.config.themes.themes)) App.config.themes.themes = [];
localCustoms.forEach(lt => {
const idx = App.config.themes.themes.findIndex(t => t.id === lt.id);
if (idx >= 0) App.config.themes.themes[idx] = lt;
else App.config.themes.themes.push(lt);
});
}
} catch (e) {}
populateThemeSelectors();
// Apply theme: localStorage preference or config.toml default_theme
const savedTheme = localStorage.getItem('cd_theme');
const activeTheme = savedTheme || App.config?.themes?.default_theme || 'amber-charcoal';
applyTheme(activeTheme);
// Configure Global Refresh button visibility (off by default)
updateGlobalRefreshButtonVisibility();
// Configure Logout vs Exit button
updateLogoutOrExitButton();
// Configure Trash Settings UI
syncTrashSettingsUI();
// Configure Windows Native & Lock Detection UI
syncWindowsNativeOpsUI();
// Configure Top Header Hostname Badge
updateHostnameBadge();
// Configure Standalone Desktop Feature Isolation
updateStandaloneUI();
}
} catch (e) {
console.error('Config fetch failed:', e);
}
}
function syncTrashSettingsUI() {
const savedTrash = localStorage.getItem('cd_trash_enabled');
if (savedTrash !== null) {
App.trashEnabled = (savedTrash === 'true');
} else if (App.trashEnabled === undefined) {
App.trashEnabled = (App.config?.paranoid?.trash_enabled !== false);
}
const savedCustom = localStorage.getItem('cd_custom_trash_dir');
if (savedCustom !== null) {
App.customTrashDir = savedCustom;
} else if (App.customTrashDir === undefined) {
App.customTrashDir = App.config?.paranoid?.custom_trash_dir || '';
}
const userTrashEl = document.getElementById('setting-user-trash-enabled');
const adminTrashEl = document.getElementById('setting-trash-enabled');
if (userTrashEl) userTrashEl.checked = App.trashEnabled;
if (adminTrashEl) adminTrashEl.checked = App.trashEnabled;
const userCustomEl = document.getElementById('setting-user-custom-trash');
const adminCustomEl = document.getElementById('setting-custom-trash-dir');
if (userCustomEl) userCustomEl.value = App.customTrashDir;
if (adminCustomEl) adminCustomEl.value = App.customTrashDir;
if (typeof updatePlatformLabels === 'function') updatePlatformLabels();
}
function syncWindowsNativeOpsUI() {
const savedNative = localStorage.getItem('cd_windows_native_ops');
if (savedNative !== null) {
App.windowsNativeOps = (savedNative === 'true');
} else if (App.windowsNativeOps === undefined) {
App.windowsNativeOps = (App.config?.paranoid?.windows_native_file_ops !== false);
}
const savedLocks = localStorage.getItem('cd_detect_file_locks');
if (savedLocks !== null) {
App.detectFileLocks = (savedLocks === 'true');
} else if (App.detectFileLocks === undefined) {
App.detectFileLocks = (App.config?.paranoid?.detect_locking_processes !== false);
}
const genNativeEl = document.getElementById('setting-windows-native-ops');
const adminNativeEl = document.getElementById('admin-setting-windows-native-ops');
if (genNativeEl) genNativeEl.checked = App.windowsNativeOps;
if (adminNativeEl) adminNativeEl.checked = App.windowsNativeOps;
const genLocksEl = document.getElementById('setting-detect-file-locks');
const adminLocksEl = document.getElementById('admin-setting-detect-file-locks');
if (genLocksEl) genLocksEl.checked = App.detectFileLocks;
if (adminLocksEl) adminLocksEl.checked = App.detectFileLocks;
if (typeof updatePlatformLabels === 'function') updatePlatformLabels();
}
function toggleWindowsNativeOpsSetting(enabled) {
App.windowsNativeOps = !!enabled;
localStorage.setItem('cd_windows_native_ops', App.windowsNativeOps ? 'true' : 'false');
syncWindowsNativeOpsUI();
if (typeof updatePlatformLabels === 'function') updatePlatformLabels();
if (typeof queueSaveUserPreferencesToServer === 'function') queueSaveUserPreferencesToServer();
if (Array.isArray(App.panes)) {
App.panes.forEach((p, idx) => {
if (isTrashDirectory(p?.path)) {
loadPaneDirectory(idx, 'trash://', false);
}
});
}
showToast(App.windowsNativeOps
? 'Windows Native Recycle Bin enabled ($Recycle.Bin)'
: 'Brum Built-in Trash enabled (.local/share/Trash)', 'info');
}
function toggleDetectFileLocksSetting(enabled) {
App.detectFileLocks = !!enabled;
localStorage.setItem('cd_detect_file_locks', App.detectFileLocks ? 'true' : 'false');
syncWindowsNativeOpsUI();
if (typeof queueSaveUserPreferencesToServer === 'function') queueSaveUserPreferencesToServer();
showToast(App.detectFileLocks
? 'Restart Manager lock detection enabled (pinpoints active applications locking files)'
: 'Lock detection disabled', 'info');
}
function toggleTrashSetting(enabled) {
App.trashEnabled = !!enabled;
localStorage.setItem('cd_trash_enabled', App.trashEnabled ? 'true' : 'false');
syncTrashSettingsUI();
const binName = getTrashDisplayName(true);
const targetName = isWindowsHost() ? 'Recycle Bin' : 'trash';
showToast(App.trashEnabled ? `${binName} enabled (deleted items are moved to ${targetName})` : `${binName} disabled (deleted items will be permanently removed)`, 'info');
}
function saveCustomTrashDir(dir) {
App.customTrashDir = (dir || '').trim();
localStorage.setItem('cd_custom_trash_dir', App.customTrashDir);
syncTrashSettingsUI();
if (App.customTrashDir) {
showToast(`Custom trash directory configured: ${App.customTrashDir}`, 'info');
} else {
showToast('Custom trash cleared (using standard system ' + (isWindowsHost() ? 'Recycle Bin' : 'trash') + ')', 'info');
}
}
function updateGlobalRefreshButtonVisibility() {
const saved = localStorage.getItem('cd_show_global_refresh');
const show = saved !== null ? (saved === 'true') : (App.config?.ui?.show_global_refresh === true);
const btn = document.getElementById('btn-refresh-all');
const sep = document.getElementById('sep-refresh-all');
if (btn) btn.style.display = show ? 'inline-flex' : 'none';
if (sep) sep.style.display = show ? 'block' : 'none';
}
function toggleGlobalRefreshButton(enabled) {
localStorage.setItem('cd_show_global_refresh', enabled);
updateGlobalRefreshButtonVisibility();
queueSaveUserPreferencesToServer();
showToast(enabled ? 'Global Header Refresh button enabled' : 'Global Header Refresh button hidden', 'info');
}
function toggleCustomThemeCreator() {
const el = document.getElementById('custom-theme-creator');
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function saveCustomBrowserTheme() {
const nameInput = document.getElementById('custom-theme-name');
const name = nameInput?.value.trim() || 'Custom Theme';
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'custom-' + Date.now();
const accent = document.getElementById('custom-theme-accent')?.value || '#00e5ff';
const bg_dark = document.getElementById('custom-theme-bg-dark')?.value || '#0b0f14';
const bg_panel = document.getElementById('custom-theme-bg-panel')?.value || '#111822';
const bg_active = bg_panel;
const border = accent;
const text_main = '#f4f4f5';
const text_muted = '#a1a1aa';
const newTheme = {
id,
name,
bg_dark,
bg_panel,
bg_active,
accent,
accent_hover: accent,
text_main,
text_muted,
border
};
if (!App.config) App.config = {};
if (!App.config.themes) App.config.themes = { themes: [] };
if (!Array.isArray(App.config.themes.themes)) App.config.themes.themes = [];
const existingIdx = App.config.themes.themes.findIndex(t => t.id === id);
if (existingIdx >= 0) App.config.themes.themes[existingIdx] = newTheme;
else App.config.themes.themes.push(newTheme);
let localCustoms = [];
try {
localCustoms = JSON.parse(localStorage.getItem('cd_custom_themes') || '[]');
} catch (e) {}
localCustoms = localCustoms.filter(t => t.id !== id);
localCustoms.push(newTheme);
localStorage.setItem('cd_custom_themes', JSON.stringify(localCustoms));
populateThemeSelectors();
applyTheme(id);
toggleCustomThemeCreator();
queueSaveUserPreferencesToServer();
showToast(`Custom theme "${name}" created and applied!`, 'success');
}
function exportCustomThemeToml() {
const name = document.getElementById('custom-theme-name')?.value.trim() || 'Custom Theme';
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'custom';
const accent = document.getElementById('custom-theme-accent')?.value || '#00e5ff';
const bg_dark = document.getElementById('custom-theme-bg-dark')?.value || '#0b0f14';
const bg_panel = document.getElementById('custom-theme-bg-panel')?.value || '#111822';
const toml = `# Brum Theme Definition
id = "${id}"
name = "${name}"
bg_dark = "${bg_dark}"
bg_panel = "${bg_panel}"
bg_active = "${bg_panel}"
accent = "${accent}"
accent_hover = "${accent}"
text_main = "#f4f4f5"
text_muted = "#a1a1aa"
border = "${accent}"
`;
const blob = new Blob([toml], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${id}.toml`;
a.click();
showToast(`Exported ${id}.toml! You can drop this into ~/.config/brum/themes/`, 'info');
}
const defaultThemeList = [
{ id: 'amber-charcoal', name: 'Woofsons Amber Charcoal', bg_dark: '#121214', bg_panel: '#18181b', accent: '#f59e0b', text_main: '#f4f4f5' },
{ id: 'zink', name: 'Woofsons Amber Zink', bg_dark: '#fafafa', bg_panel: '#ffffff', accent: '#d97706', text_main: '#18181b' },
{ id: 'gruvbox', name: 'Gruvbox Dark', bg_dark: '#1d2021', bg_panel: '#282828', accent: '#fabd2f', text_main: '#ebdbb2' },
{ id: 'catppuccin-mocha', name: 'Catppuccin Mocha', bg_dark: '#181825', bg_panel: '#1e1e2e', accent: '#cba6f7', text_main: '#cdd6f4' },
{ id: 'catppuccin-latte', name: 'Catppuccin Latte (Light)', bg_dark: '#dce0e8', bg_panel: '#eff1f5', accent: '#8839ef', text_main: '#4c4f69' },
{ id: 'tokyo-night', name: 'Tokyo Night', bg_dark: '#16161e', bg_panel: '#1a1b26', accent: '#7aa2f7', text_main: '#c0caf5' },
{ id: 'monokai', name: 'Monokai Pro', bg_dark: '#1e1f1c', bg_panel: '#272822', accent: '#ffd866', text_main: '#f8f8f2' },
{ id: 'solarized-dark', name: 'Solarized Dark', bg_dark: '#00212b', bg_panel: '#002b36', accent: '#268bd2', text_main: '#839496' },
{ id: 'ayu-dark', name: 'Ayu Dark', bg_dark: '#0b0e14', bg_panel: '#0f1419', accent: '#e6b450', text_main: '#e6e1cf' },
{ id: 'nord', name: 'Nord Frost', bg_dark: '#242933', bg_panel: '#2e3440', accent: '#88c0d0', text_main: '#eceff4' },
{ id: 'dracula', name: 'Dracula Dark', bg_dark: '#1e1f29', bg_panel: '#282a36', accent: '#bd93f9', text_main: '#f8f8f2' },
{ id: 'midnight-blue', name: 'Midnight Commander Blue', bg_dark: '#000044', bg_panel: '#000088', accent: '#00ffff', text_main: '#ffffff' },
{ id: 'skumring', name: 'Larvikite Skumring', bg_dark: '#0a0e14', bg_panel: '#111822', accent: '#38bdf8', text_main: '#e6edf3' },
{ id: 'demring', name: 'Larvikite Demring', bg_dark: '#eef2f6', bg_panel: '#f7fafc', accent: '#0e7490', text_main: '#0f172a' },
{ id: 'trollnatt', name: 'Larvikite Trollnatt', bg_dark: '#0b100d', bg_panel: '#121914', accent: '#4ade80', text_main: '#edf4ee' },
{ id: 'myrtaake', name: 'Larvikite Myrtåke', bg_dark: '#edf2ee', bg_panel: '#f5f9f6', accent: '#15803d', text_main: '#0f1712' },
{ id: 'bergtatt', name: 'Kittelsen Bergtatt', bg_dark: '#0a0c0f', bg_panel: '#11141a', accent: '#d9a042', text_main: '#e8e2d8' },
{ id: 'soria-moria', name: 'Kittelsen Soria Moria', bg_dark: '#ebe5dc', bg_panel: '#f5f0e6', accent: '#b87a1f', text_main: '#1c1815' },
{ id: 'pestanatt', name: 'Kittelsen Pestanatt', bg_dark: '#0b090a', bg_panel: '#141011', accent: '#dc2626', text_main: '#e6dede' },
{ id: 'sotslette', name: 'Kittelsen Sotslette', bg_dark: '#ece6dc', bg_panel: '#f5f0e6', accent: '#991b1b', text_main: '#1c1517' }
];
function populateThemeSelectors() {
const themeList = (App.config?.themes?.themes && Array.isArray(App.config.themes.themes) && App.config.themes.themes.length > 0)
? App.config.themes.themes
: defaultThemeList;
const selectors = [
document.getElementById('settings-theme-selector'),
document.getElementById('theme-selector')
];
const curVal = localStorage.getItem('cd_theme') || App.config?.themes?.default_theme || 'amber-charcoal';
selectors.forEach(sel => {
if (!sel) return;
sel.innerHTML = '';
themeList.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
if (t.id === curVal) opt.selected = true;
sel.appendChild(opt);
});
sel.value = curVal;
});
// Populate visual theme swatch cards
const grid = document.getElementById('theme-swatch-grid');
if (grid) {
grid.innerHTML = '';
themeList.forEach(t => {
const card = document.createElement('div');
card.className = `theme-swatch-card ${t.id === curVal ? 'active' : ''}`;
card.id = `theme-card-${t.id}`;
card.onclick = () => applyTheme(t.id);
card.innerHTML = `
<div class="theme-swatch-preview">
<span class="theme-swatch-dot" style="background: ${t.bg_dark};" title="Background: ${t.bg_dark}"></span>
<span class="theme-swatch-dot" style="background: ${t.bg_panel};" title="Panel: ${t.bg_panel}"></span>
<span class="theme-swatch-dot" style="background: ${t.accent};" title="Accent: ${t.accent}"></span>
<span class="theme-swatch-dot" style="background: ${t.text_main};" title="Text: ${t.text_main}"></span>
</div>
<div class="theme-swatch-name">${escapeHtml(t.name)}</div>
`;
grid.appendChild(card);
});
}
}
async function loadSystemUsersGroups() {
try {
const res = await fetch('/api/system/users-groups');
if (res.ok) {
const data = await res.json();
App.systemUsers = data.users;
App.systemGroups = data.groups;
}
} catch (e) {
console.error('Users/Groups load error:', e);
}
}
function renderAllPanes(optimisticOnly = false) {
stashDockedTerminal();
const container = document.getElementById('panes-grid');
if (!container) return;
container.className = `panes-container ${App.layout}`;
applyPaneSplits();
container.innerHTML = '';
let visibleCount = getVisiblePaneCount();
for (let i = 0; i < visibleCount; i++) {
const pane = App.panes[i];
if (!pane) continue;
const paneEl = createPaneElement(pane, i);
container.appendChild(paneEl);
renderPaneTabs(i);
if (pane.dockedTool) {
mountDockedTool(i);
} else {
if (pane.showTree) {
loadPaneDirectoryTree(i);
}
// Immediate optimistic render if cached entries exist
if (pane.entries && pane.entries.length > 0) {
renderPaneBreadcrumbs(i, pane.path);
renderPaneTable(i);
}
}
}
applyPaneColors();
applyAllColumnWidths();
if (window.lucide) lucide.createIcons();
if (optimisticOnly) {
return;
}
// Parallel batched directory revalidation across all visible file panes
const revalPromises = [];
for (let i = 0; i < visibleCount; i++) {
const pane = App.panes[i];
if (pane && !pane.dockedTool) {
revalPromises.push(loadPaneDirectory(i, pane.path, false, null, false, false, true));
}
}
if (revalPromises.length > 0) {
Promise.allSettled(revalPromises);
}
}
function createPaneElement(pane, index) {
const el = document.createElement('div');
el.className = `pane ${index === App.activePaneIndex ? 'active' : ''}`;
el.id = `pane-${index}`;
el.onclick = (e) => {
setActivePane(index);
if (!e.target.closest('tr.file-row, .grid-gallery-card, .compact-list-item, input, button, select, textarea, .col-resizer, .pane-tree-resizer, .pane-filter-wrapper, thead, .pull-refresh-indicator, .context-menu, .tree-node, .tree-item, .pane-tab-item, .breadcrumb-item, .pane-toolbar')) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
const p = App.panes[index];
if (p && p.selected.size > 0) {
p.selected.clear();
p.cursorIndex = -1;
renderPaneTable(index);
updateMobileBottomBar();
}
}
}
};
// Hover activation (Focus follows mouse)
el.onmouseenter = () => {
if (App.activateOnHover && App.activePaneIndex !== index) {
if (!window._draggingPaneIndex && !window._isResizingSplitter && !window._isResizingCol && !window._isResizingTree) {
setActivePane(index);
}
}
};
// Forward wheel events anywhere over the pane (header, breadcrumbs, columns, footer) to main view
el.addEventListener('wheel', (e) => {
if (e.target.closest('.pane-tree-sidebar, .pane-tabs-scroll, textarea, input, select, .dropdown-menu, .context-menu, .docked-core-container, .viewer-container')) return;
const mainView = el.querySelector('.pane-main-view');
if (!mainView) return;
if (!e.target.closest('.pane-main-view')) {
if (e.deltaY) mainView.scrollTop += e.deltaY;
if (e.deltaX) mainView.scrollLeft += e.deltaX;
}
}, { passive: true });
// Enable HTML5 Drag & Drop Target
el.ondragenter = (e) => {
e.preventDefault();
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) {
if (window._draggingPaneIndex !== index) {
e.dataTransfer.dropEffect = 'move';
el.classList.add('pane-reorder-target');
}
return;
}
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
el.classList.add('drag-over');
};
el.ondragover = (e) => {
e.preventDefault();
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) {
if (window._draggingPaneIndex !== index) {
e.dataTransfer.dropEffect = 'move';
el.classList.add('pane-reorder-target');
}
return;
}
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
el.classList.add('drag-over');
};
el.ondragleave = () => {
el.classList.remove('drag-over');
el.classList.remove('pane-reorder-target');
};
el.ondrop = (e) => handlePaneDrop(e, index);
const visibleCount = getVisiblePaneCount();
const colors = getPaneColors();
const globalAngle = localStorage.getItem('cd_border_angle') || '45deg';
const paneTitle = pane.customName || `${index + 1}`;
const colorConf = colors[index] || 'default';
const resolved = typeof resolvePaneColorConfig === 'function' ? resolvePaneColorConfig(colorConf, globalAngle) : { activeC1: '#f59e0b', activeC2: '#f97316', angle: '45deg', id: 'default' };
const activeHex = resolved.activeC1;
const badgeGradient = `linear-gradient(${resolved.angle}, ${resolved.activeC1}, ${resolved.activeC2})`;
const isDraggableAttr = App.paneReorderEnabled !== false ? `draggable="true" ondragstart="handlePaneBadgeDragStart(event, ${index})" ondragend="handlePaneBadgeDragEnd(event, ${index})"` : '';
const badgeTitle = `Pane ${index + 1}: ${escapeHtml(paneTitle)} (${App.paneReorderEnabled !== false ? 'Drag: Reorder, ' : ''}Click / Long Press: Settings)`;
if (pane.dockedTool) {
const tool = pane.dockedTool;
let toolTitleHtml = '';
if (tool.startsWith('plugin:') || tool.startsWith('chewtoy:')) {
const pId = tool.replace(/^(plugin|chewtoy):/, '');
const pInfo = (window.installedChewToys || []).find(p => p.id === pId);
const iconSrc = getChewtoyIconUrl(pInfo);
const name = pInfo?.name || pId;
toolTitleHtml = `<img src="${escapeHtml(iconSrc)}" alt="${escapeHtml(name)}" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;" onerror="this.src='assets/amber-frameless-apps.webp'"> ${escapeHtml(name)}`;
} else {
const toolTitles = {
'editor': '<img src="assets/edit.webp" alt="Editor" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Editor',
'notedog': '<img src="assets/note.webp" alt="Notes" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Notes',
'terminal': '<img src="assets/term.webp" alt="Terminal" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Terminal Console',
'calculator': '<img src="assets/calc.webp" alt="Calculator" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Calculator',
'renamer': '<i data-lucide="file-signature" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> Batch Renamer',
'hexeditor': '<i data-lucide="binary" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> Hex Editor',
'git': '<img src="assets/amber-git.webp" alt="Git" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;" onerror="this.src=\'assets/amber-frameless-apps.webp\'"> Git Manager',
'tasks': '<img src="assets/task.webp" alt="Tasks" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Transfers & Queue',
'tetradog': '<img src="assets/amber-tetris.webp" alt="Tetris" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Tetris',
'sounddog': '<img src="assets/amber-media.webp" alt="Audio Player" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Audio Player',
'mediaplayer': '<img src="assets/media.webp" alt="Media Player" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Media Player',
'duplicates': '<i data-lucide="copy-check" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> Duplicate Finder',
'splitter': '<i data-lucide="scissors" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> File Splitter & Combiner',
'pdf': '<img src="assets/amber-pdftool.webp" alt="PDF Studio" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;" onerror="this.src=\'assets/amber-frameless-apps.webp\'"> PDF Studio',
'tageditor': '<i data-lucide="tag" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> Tag Editor',
'logviewer': '<i data-lucide="scroll-text" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> Log Viewer',
'cad': '<i data-lucide="box" style="width: 14px; height: 14px; color: var(--accent); vertical-align: middle; margin-right: 4px;"></i> 3D CAD Studio',
'shares': '<img src="assets/sharemgr.webp" alt="Sharing Center" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Sharing Center',
'diskusage': '<img src="assets/amber-piechart.webp" alt="Disk Usage" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Disk Usage',
'du': '<img src="assets/amber-piechart.webp" alt="Disk Usage" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Disk Usage'
};
toolTitleHtml = toolTitles[tool] || escapeHtml(tool);
}
el.innerHTML = `
<div class="pane-header">
<button class="pane-badge-btn" id="pane-badge-btn-${index}"
${isDraggableAttr}
ontouchstart="handlePaneBadgeTouchStart(event, ${index})"
ontouchmove="handlePaneBadgeTouchMove(event, ${index})"
ontouchend="handlePaneBadgeTouchEnd(event, ${index})"
ontouchcancel="handlePaneBadgeTouchCancel(event, ${index})"
onclick="event.stopPropagation(); handlePaneBadgeClick(event, ${index})"
oncontextmenu="event.preventDefault(); event.stopPropagation(); openPaneSettingsMenu(event, ${index})"
title="${badgeTitle}">
<span class="pane-badge-indicator" id="pane-badge-indicator-${index}" style="background:${badgeGradient};"></span>
<span class="pane-badge-text" id="pane-badge-text-${index}">${escapeHtml(paneTitle)}</span>
</button>
<div style="display: flex; align-items: center; gap: 8px; flex: 1;">
<span style="font-weight: 700; font-size: 12px; color: var(--accent);">${toolTitleHtml}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-icon btn-panel-control" onclick="event.stopPropagation(); undockToolFromPane(${index})" title="Undock to Floating Window (Float)"><i data-lucide="external-link"></i></button>
<button class="btn btn-icon btn-panel-control modal-close-btn" onclick="event.stopPropagation(); closeDockedTool(${index})" title="Close Docked Tool"><i data-lucide="x"></i></button>
</div>
</div>
<div class="pane-content" id="pane-content-${index}" style="padding: 0; overflow: hidden; display: flex; flex-direction: column; position: relative;">
<div class="pane-ghost-watermark ${paneTitle.length > 2 ? 'pane-ghost-long' : ''}" id="pane-ghost-${index}" style="color: ${resolved.id !== 'default' ? resolved.activeC1 : 'var(--accent)'};" aria-hidden="true">${escapeHtml(paneTitle)}</div>
<div id="docked-tool-mount-${index}" style="flex: 1; height: 100%; width: 100%; display: flex; flex-direction: column; overflow: hidden; position: relative; z-index: 1;"></div>
</div>
`;
return el;
}
el.innerHTML = `
<div class="pane-header">
<!-- Leftmost Unified Pane Number & Settings Badge Button (Desktop, Laptop, Tablet, Foldable, Phone) -->
<button class="pane-badge-btn" id="pane-badge-btn-${index}"
${isDraggableAttr}
ontouchstart="handlePaneBadgeTouchStart(event, ${index})"
ontouchmove="handlePaneBadgeTouchMove(event, ${index})"
ontouchend="handlePaneBadgeTouchEnd(event, ${index})"
ontouchcancel="handlePaneBadgeTouchCancel(event, ${index})"
onclick="event.stopPropagation(); handlePaneBadgeClick(event, ${index})"
oncontextmenu="event.preventDefault(); event.stopPropagation(); openPaneSettingsMenu(event, ${index})"
title="${badgeTitle}">
<span class="pane-badge-indicator" id="pane-badge-indicator-${index}" style="background:${badgeGradient};"></span>
<span class="pane-badge-text" id="pane-badge-text-${index}">${escapeHtml(paneTitle)}</span>
</button>
<!-- Desktop Navigation & Tool Buttons (Hidden on touch viewports <= 1024px) -->
<div class="pane-nav-btns desktop-only-tool">
<button onclick="navPaneHistory(${index}, -1)" title="Back"><i data-lucide="arrow-left"></i></button>
<button onclick="navPaneHistory(${index}, 1)" title="Forward"><i data-lucide="arrow-right"></i></button>
<button onclick="navPaneUp(${index})" title="Parent Directory (Backspace)"><i data-lucide="arrow-up"></i></button>
<button class="btn btn-icon pane-filter-toggle-btn ${App.panes[index]?.showFilter ? 'active' : ''}" id="btn-filter-toggle-${index}" onclick="event.stopPropagation(); togglePaneFilter(${index})" title="Toggle Quick Filter (/ or Ctrl+F)"><i data-lucide="filter"></i></button>
<button class="btn btn-icon pane-tree-btn desktop-header-tool ${App.panes[index]?.showTree ? 'active' : ''}" id="btn-tree-${index}" onclick="event.stopPropagation(); togglePaneTree(${index});" title="Toggle Folder Tree Sidebar"><i data-lucide="folder-tree"></i></button>
<button class="btn btn-icon pane-branch-btn desktop-header-tool ${App.panes[index]?.isBranchView ? 'active' : ''}" id="btn-branch-${index}" onclick="event.stopPropagation(); toggleBranchView(${index});" title="Toggle Flat Branch View (Ctrl+B)"><i data-lucide="git-branch"></i></button>
</div>
<!-- Path bar & Breadcrumbs (Expanded with flexible width and touch scrolling) -->
<div class="pane-path-bar" onclick="enablePathInput(${index})">
<div class="pane-breadcrumbs" id="pane-crumbs-${index}"></div>
<input type="text" class="pane-path-input" id="pane-input-${index}" onkeydown="handlePathKey(event, ${index})" onblur="disablePathInput(${index})">
<button class="btn btn-icon pane-refresh-btn desktop-only-tool" id="btn-refresh-pane-${index}" onclick="event.stopPropagation(); refreshPane(${index});" title="Refresh Pane (${index === 0 ? 'F5' : 'Ctrl+R'})">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<!-- Unified Places Hub (Visible on ALL viewports: Phone, Tablet, Foldable, Desktop) -->
<div class="pane-favorites-wrapper">
<button class="btn btn-icon pane-places-btn" id="btn-favorites-${index}" onclick="openPaneFavoritesMenu(event, ${index})" oncontextmenu="event.preventDefault(); openBookmarksManager();" title="Places, Bookmarks & Fleet Nodes (Left-click: Quick Jump, Right-click: Manage)">
<i data-lucide="paw-print"></i>
</button>
</div>
<!-- Transfer & Ingest Split Action Button (Desktop & Toolbar) -->
<div class="pane-transfer-wrapper desktop-header-tool" id="pane-transfer-wrap-${index}">
<div class="btn-group pane-transfer-group">
<button class="btn btn-icon pane-transfer-main-btn" id="btn-transfer-main-${index}" onclick="executePane1ClickTransfer(${index})" oncontextmenu="event.preventDefault(); openPaneTransferMenu(event, ${index})" title="${getPane1ClickTransferTooltip()}">
<i data-lucide="${getPane1ClickTransferIcon()}"></i>
</button>
<button class="btn btn-icon pane-transfer-dropdown-btn" id="btn-transfer-menu-${index}" onclick="openPaneTransferMenu(event, ${index})" title="Transfer & Ingest Options (Upload, Download, Inter-Pane, Share)">
<i data-lucide="chevron-down" style="width: 10px; height: 10px;"></i>
</button>
</div>
</div>
<!-- Combined Pane Menu Button (Visible on ALL viewports, providing overflow for mobile/foldable) -->
<div class="pane-tools-wrapper">
<button class="btn btn-icon pane-tools-btn" id="pane-tools-btn-${index}" onclick="openPaneToolsMenu(event, ${index})" title="Pane Actions & Tools">
<i data-lucide="more-vertical"></i>
</button>
</div>
<div class="pane-filter-wrapper" id="pane-filter-wrap-${index}" style="display: ${App.panes[index]?.showFilter ? 'flex' : 'none'};">
<i data-lucide="search" class="pane-filter-icon"></i>
<input type="text" class="pane-quick-filter" placeholder="Filter (/)..." id="pane-filter-${index}" value="${escapeHtml(App.panes[index]?.filterText || '')}" oninput="handleFilterInput(${index}, this.value)" onkeydown="handleFilterKey(event, ${index})">
<button class="pane-filter-clear-btn" onclick="clearPaneFilter(${index})" title="Clear filter (Esc)">✕</button>
</div>
</div>
<div class="pane-tab-strip" id="pane-tab-strip-${index}"></div>
<div class="pane-content" id="pane-content-${index}">
<div class="pane-split-wrapper" id="pane-split-${index}">
<div class="pane-tree-sidebar" id="pane-tree-${index}" style="display: ${App.panes[index]?.showTree ? 'flex' : 'none'};"></div>
<div class="pane-tree-resizer" id="pane-tree-resizer-${index}" style="display: ${App.panes[index]?.showTree ? 'block' : 'none'};" onmousedown="initTreeResize(event, ${index})"></div>
<div class="pane-main-view" id="pane-main-${index}">
<div class="pane-ghost-watermark ${paneTitle.length > 2 ? 'pane-ghost-long' : ''}" id="pane-ghost-${index}" style="color: ${resolved.id !== 'default' ? resolved.activeC1 : 'var(--accent)'};" aria-hidden="true">${escapeHtml(paneTitle)}</div>
<div class="pull-refresh-indicator" id="pull-refresh-${index}" style="display: none; height: 0px; overflow: hidden; justify-content: center; align-items: center; background: rgba(0,0,0,0.3); color: var(--accent); font-size: 11px; font-weight: 700; transition: height 0.1s linear; border-bottom: 1px dashed var(--border);">
<i data-lucide="rotate-cw" class="pull-refresh-spinner" style="width: 14px; margin-right: 6px;"></i>
<span class="pull-refresh-label">Pull down to refresh...</span>
</div>
<table class="file-table" id="pane-table-${index}">
<thead>
<tr id="pane-header-row-${index}" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<th class="col-header col-icon" style="text-align: center; padding: 0 4px; vertical-align: middle;">
<button class="pane-col-config-btn" onclick="event.stopPropagation(); openColumnHeaderContextMenu(event, ${index});" oncontextmenu="event.preventDefault(); event.stopPropagation(); openColumnHeaderContextMenu(event, ${index});" title="Configure Table Columns & Auto-Fit">
<i data-lucide="sliders-horizontal"></i>
</button>
</th>
<th class="col-header col-name" id="col-header-${index}-name" onclick="sortPane(${index}, 'name')" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Name</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'name')" ontouchstart="initColResize(event, ${index}, 'name')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'name')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-ext" id="col-header-${index}-ext" onclick="sortPane(${index}, 'ext')" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Ext</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'ext')" ontouchstart="initColResize(event, ${index}, 'ext')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'ext')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-size" id="col-header-${index}-size" onclick="sortPane(${index}, 'size')" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Size</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'size')" ontouchstart="initColResize(event, ${index}, 'size')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'size')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-modified" id="col-header-${index}-modified" onclick="sortPane(${index}, 'modified')" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Modified</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'modified')" ontouchstart="initColResize(event, ${index}, 'modified')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'modified')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-created" id="col-header-${index}-created" style="display: none;" onclick="sortPane(${index}, 'created')" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Created</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'created')" ontouchstart="initColResize(event, ${index}, 'created')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'created')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-mode" id="col-header-${index}-mode" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Mode</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'mode')" ontouchstart="initColResize(event, ${index}, 'mode')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'mode')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-owner" id="col-header-${index}-owner" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Owner</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'owner')" ontouchstart="initColResize(event, ${index}, 'owner')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'owner')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-group" id="col-header-${index}-group" style="display: none;" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Group</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'group')" ontouchstart="initColResize(event, ${index}, 'group')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'group')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-hash" id="col-header-${index}-hash" style="display: none;" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>SHA-256</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'hash')" ontouchstart="initColResize(event, ${index}, 'hash')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'hash')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
<th class="col-header col-tags" id="col-header-${index}-tags" style="display: none;" oncontextmenu="event.preventDefault(); openColumnHeaderContextMenu(event, ${index});">
<span>Tags</span>
<div class="col-resizer" onpointerdown="initColResize(event, ${index}, 'tags')" ontouchstart="initColResize(event, ${index}, 'tags')" onclick="event.stopPropagation()" ondblclick="autoFitColumn(${index}, 'tags')" title="Drag to resize | Double-click to auto-fit"></div>
</th>
</tr>
</thead>
<tbody id="pane-tbody-${index}"></tbody>
</table>
<div class="grid-gallery-container size-${App.panes[index]?.gridSize || 'md'}" id="pane-grid-${index}" style="display: none;"></div>
<div class="compact-list-container" id="pane-compact-${index}" style="display: none;"></div>
</div>
</div>
</div>
<div class="pane-footer" id="pane-footer-${index}">
<div class="pane-footer-info" id="pane-footer-info-${index}">
<span class="pane-footer-counts" id="pane-footer-counts-${index}">0 dirs, 0 files</span>
</div>
<div class="pane-footer-right" id="pane-footer-right-${index}">
<span class="pane-footer-size" id="pane-footer-size-${index}">Total: 0 B</span>
<div class="pane-footer-sep"></div>
<div class="pane-footer-viewmodes">
<button class="pane-footer-view-btn ${App.panes[index]?.viewMode === 'details' ? 'active' : ''}" id="btn-view-details-${index}" onclick="event.stopPropagation(); setPaneViewMode(${index}, 'details');" title="Details Table View"><i data-lucide="list"></i></button>
<button class="pane-footer-view-btn ${App.panes[index]?.viewMode === 'grid' ? 'active' : ''}" id="btn-view-grid-${index}" onclick="event.stopPropagation(); setPaneViewMode(${index}, 'grid');" title="Thumbnail Gallery (Grid)"><i data-lucide="layout-grid"></i></button>
<button class="pane-footer-view-btn ${App.panes[index]?.viewMode === 'compact' ? 'active' : ''}" id="btn-view-compact-${index}" onclick="event.stopPropagation(); setPaneViewMode(${index}, 'compact');" title="Compact Multi-Column List"><i data-lucide="columns-3"></i></button>
</div>
</div>
</div>
`;
const content = el.querySelector('.pane-content');
if (content) {
content.ondblclick = (e) => {
if (e.target.closest('tr.file-row, .grid-gallery-card, .compact-list-item')) return;
if (App.dblclickUpDir) {
navPaneUp(index);
}
};
content.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return false;
if (e.target.closest('tr.file-row, .grid-gallery-card, .compact-list-item')) return;
setActivePane(index);
showEmptySpaceContextMenu(e.clientX, e.clientY, index);
};
// Mobile Pull-to-Refresh
const mainView = el.querySelector(`#pane-main-${index}`) || content;
let pullStartY = 0;
let isPulling = false;
let pullDistance = 0;
mainView.addEventListener('touchstart', (e) => {
if (mainView.scrollTop <= 0 && e.touches.length === 1) {
pullStartY = e.touches[0].clientY;
isPulling = false;
pullDistance = 0;
}
}, { passive: true });
mainView.addEventListener('touchmove', (e) => {
if (e.touches.length !== 1) return;
const currentY = e.touches[0].clientY;
const diffY = currentY - pullStartY;
if (mainView.scrollTop <= 0 && diffY > 20) {
isPulling = true;
pullDistance = Math.min(80, (diffY - 20) * 0.45);
const indicator = document.getElementById(`pull-refresh-${index}`);
if (indicator) {
indicator.style.display = 'flex';
indicator.style.height = `${pullDistance}px`;
const label = indicator.querySelector('.pull-refresh-label');
const icon = indicator.querySelector('.pull-refresh-spinner');
if (pullDistance > 45) {
if (label) label.textContent = 'Release to refresh...';
if (icon) icon.style.transform = `rotate(${pullDistance * 4}deg)`;
} else {
if (label) label.textContent = 'Pull down to refresh...';
if (icon) icon.style.transform = `rotate(${pullDistance * 2}deg)`;
}
}
} else {
if (isPulling) {
pullDistance = 0;
const indicator = document.getElementById(`pull-refresh-${index}`);
if (indicator) {
indicator.style.display = 'none';
indicator.style.height = '0px';
}
isPulling = false;
}
}
}, { passive: true });
mainView.addEventListener('touchend', () => {
if (isPulling && pullDistance > 45) {
const indicator = document.getElementById(`pull-refresh-${index}`);
if (indicator) {
const label = indicator.querySelector('.pull-refresh-label');
const icon = indicator.querySelector('.pull-refresh-spinner');
if (label) label.textContent = 'Refreshing...';
if (icon) icon.classList.add('animate-spin');
triggerHaptic(30);
}
setTimeout(() => {
refreshPane(index);
const ind = document.getElementById(`pull-refresh-${index}`);
if (ind) {
ind.style.display = 'none';
ind.style.height = '0px';
const icon = ind.querySelector('.pull-refresh-spinner');
if (icon) icon.classList.remove('animate-spin');
}
}, 300);
} else {
const indicator = document.getElementById(`pull-refresh-${index}`);
if (indicator) {
indicator.style.display = 'none';
indicator.style.height = '0px';
}
}
isPulling = false;
pullDistance = 0;
}, { passive: true });
mainView.addEventListener('scroll', () => {
const isScrolled = mainView.scrollTop > 2;
if (mainView.classList.contains('is-scrolled') !== isScrolled) {
mainView.classList.toggle('is-scrolled', isScrolled);
}
handlePaneScrollSync(index, mainView);
}, { passive: true });
initPaneMarqueeSelection(mainView, index);
}
attachPaneResizerHandles(el, index, App.layout);
return el;
}
// ---------------- 🖱️ MARQUEE / RUBBERBAND DRAG-AND-SELECT ----------------
function initPaneMarqueeSelection(mainViewEl, paneIndex) {
if (!mainViewEl) return;
mainViewEl.addEventListener('mousedown', (e) => {
// Only primary left mouse button
if (e.button !== 0) return;
// Ignore touch or interactive controls / resizers / headers
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
// Drag-and-select MUST ALWAYS start on "empty" space!
// If clicking directly on any interactive element, row, card, item, or banner, let standard events handle it.
const interactiveOrRow = e.target.closest('tr.file-row, .grid-gallery-card, .compact-list-item, .parent-dir-row, .parent-dir-card, .parent-dir-item, .pane-trash-banner, .pane-branch-banner, button, input, select, textarea, a, [role="button"], .interactive, .no-marquee');
if (interactiveOrRow) {
return;
}
const pane = App.panes[paneIndex];
if (!pane) return;
const startX = e.clientX;
const startY = e.clientY;
const initialSelected = new Set(pane.selected);
let marqueeBox = null;
let isMarqueeActive = false;
function onMouseMove(moveEvent) {
const dx = Math.abs(moveEvent.clientX - startX);
const dy = Math.abs(moveEvent.clientY - startY);
if (!isMarqueeActive) {
if (dx > 5 || dy > 5) {
isMarqueeActive = true;
marqueeBox = document.createElement('div');
marqueeBox.className = 'marquee-selection-box';
document.body.appendChild(marqueeBox);
} else {
return;
}
}
const mLeft = Math.min(startX, moveEvent.clientX);
const mTop = Math.min(startY, moveEvent.clientY);
const mWidth = Math.abs(moveEvent.clientX - startX);
const mHeight = Math.abs(moveEvent.clientY - startY);
marqueeBox.style.left = mLeft + 'px';
marqueeBox.style.top = mTop + 'px';
marqueeBox.style.width = mWidth + 'px';
marqueeBox.style.height = mHeight + 'px';
const items = mainViewEl.querySelectorAll('tr.file-row:not(.parent-dir-row), .grid-gallery-card, .compact-list-item:not(.parent-dir-item)');
const newlySelected = new Set(moveEvent.ctrlKey || moveEvent.metaKey || moveEvent.shiftKey ? initialSelected : []);
items.forEach((itemEl) => {
const path = itemEl.dataset.path;
if (!path) return;
const rect = itemEl.getBoundingClientRect();
const intersects = !(rect.right < mLeft || rect.left > (mLeft + mWidth) || rect.bottom < mTop || rect.top > (mTop + mHeight));
if (intersects) {
if (moveEvent.ctrlKey || moveEvent.metaKey) {
if (initialSelected.has(path)) newlySelected.delete(path);
else newlySelected.add(path);
} else {
newlySelected.add(path);
}
itemEl.classList.add('selected');
} else {
if (!moveEvent.ctrlKey && !moveEvent.metaKey && !moveEvent.shiftKey) {
newlySelected.delete(path);
itemEl.classList.remove('selected');
} else if (initialSelected.has(path)) {
itemEl.classList.add('selected');
} else {
itemEl.classList.remove('selected');
}
}
});
pane.selected = newlySelected;
updatePaneFooter(paneIndex);
}
function onMouseUp() {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
if (isMarqueeActive && marqueeBox) {
marqueeBox.remove();
marqueeBox = null;
renderPaneTable(paneIndex);
updateMobileBottomBar();
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// Direct click on empty space outside files deselects selection
setActivePane(paneIndex);
if (pane.selected.size > 0) {
pane.selected.clear();
pane.cursorIndex = -1;
renderPaneTable(paneIndex);
updateMobileBottomBar();
}
}
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
}
function setActivePane(index) {
const visibleCount = getVisiblePaneCount();
if (visibleCount > 0 && index >= visibleCount) {
index = visibleCount - 1;
}
const prevIndex = App.activePaneIndex;
App.activePaneIndex = Math.max(0, index);
if (prevIndex !== App.activePaneIndex) {
triggerHaptic(20);
}
document.querySelectorAll('.pane').forEach((p, idx) => {
if (idx === App.activePaneIndex) p.classList.add('active');
else p.classList.remove('active');
});
updateBranchToggleState();
if (typeof updateMobileBottomBar === 'function') {
updateMobileBottomBar();
}
}
function togglePaneDotfiles(paneIndex) {
const pane = App.panes[paneIndex];
pane.showHidden = !pane.showHidden;
const btn = document.getElementById(`btn-dotfiles-${paneIndex}`);
if (btn) {
if (pane.showHidden) btn.classList.add('active');
else btn.classList.remove('active');
}
loadPaneDirectory(paneIndex, pane.path);
}
// ---------------- PER-PANE FLEET NODE HELPERS ----------------
function getPaneNode(paneIndex) {
const pane = App.panes && App.panes[paneIndex];
const nodeId = pane ? (pane.nodeId || 'local') : 'local';
if (nodeId === 'local') {
return {
id: 'local',
name: getLocalHostDisplayName(),
endpoint_url: window.location.origin,
auth_token: App.token || '',
color_accent: 'amber',
status: 'online'
};
}
const node = typeof getFleetNodeById === 'function' ? getFleetNodeById(nodeId) : null;
if (!node) {
return {
id: 'local',
name: getLocalHostDisplayName(),
endpoint_url: window.location.origin,
auth_token: App.token || '',
color_accent: 'amber',
status: 'online'
};
}
return node;
}
function getPaneEndpoint(paneIndex) {
const node = getPaneNode(paneIndex);
if (!node || node.id === 'local' || !node.endpoint_url) {
return '';
}
return node.endpoint_url.trim().replace(/\/+$/, '');
}
function getPaneAuthHeaders(paneIndex, extraHeaders = {}) {
const node = getPaneNode(paneIndex);
const headers = { ...extraHeaders };
if (node && node.id !== 'local') {
if (node.auth_token && node.auth_token.trim()) {
headers['Authorization'] = `Bearer ${node.auth_token.trim()}`;
}
} else {
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
function switchPaneNode(paneIndex, nodeId, targetPath = null) {
const pane = App.panes && App.panes[paneIndex];
if (!pane) return;
pane.nodeId = nodeId || 'local';
localStorage.setItem(`cd_pane_node_${paneIndex}`, pane.nodeId);
updatePaneNodeBadge(paneIndex);
if (typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
if (pane.nodeId === 'local') {
const defaultHome = getUserDefaultHomeDir();
const dest = targetPath || defaultHome || '/';
loadPaneDirectory(paneIndex, dest, true);
showToast(`Pane ${paneIndex + 1} switched to Local Host`, 'info');
} else {
const node = getPaneNode(paneIndex);
const dest = targetPath || '/';
loadPaneDirectory(paneIndex, dest, true);
showToast(`Pane ${paneIndex + 1} switched to Fleet Node "${node.name}"`, 'success');
if (typeof pingFleetNode === 'function') {
pingFleetNode(node.id);
}
}
}
function updatePaneNodeBadge(paneIndex) {
const node = getPaneNode(paneIndex);
const btn = document.getElementById(`pane-node-btn-${paneIndex}`);
const dot = document.getElementById(`pane-node-dot-${paneIndex}`);
const name = document.getElementById(`pane-node-name-${paneIndex}`);
if (!btn || !node) return;
if (node.id === 'local') {
btn.classList.remove('remote-active');
if (dot) dot.style.background = '#10b981';
if (name) name.textContent = 'Local';
btn.title = `Local Host (Active for Pane ${paneIndex + 1}) - Click to switch target node`;
} else {
btn.classList.add('remote-active');
let dotColor = '#94a3b8';
if (node.status === 'online') dotColor = '#10b981';
else if (node.status === 'unauthorized') dotColor = '#f59e0b';
else if (node.status === 'offline') dotColor = '#ef4444';
if (dot) dot.style.background = dotColor;
if (name) name.textContent = node.name || 'Remote';
btn.title = `Fleet Node: ${node.name} (${node.endpoint_url || ''}) - Click to switch target node`;
}
}
async function showPaneNodeDropdown(event, paneIndex) {
if (typeof closeBreadcrumbPopovers === 'function') closeBreadcrumbPopovers();
const trigger = event.currentTarget || event.target;
const rect = trigger.getBoundingClientRect();
const popover = document.createElement('div');
popover.className = 'breadcrumb-popover fleet-pane-popover';
popover.style.top = `${rect.bottom + 4}px`;
popover.style.left = `${Math.max(8, rect.left)}px`;
popover.style.minWidth = '230px';
const pane = App.panes && App.panes[paneIndex];
const currentNodeId = pane ? (pane.nodeId || 'local') : 'local';
const nodes = typeof getAllFleetNodes === 'function' ? getAllFleetNodes() : [];
let html = `
<div class="breadcrumb-popover-header" style="display: flex; align-items: center; justify-content: space-between;">
<span>Pane ${paneIndex + 1} Target Node</span>
<span style="font-size: 9px; opacity: 0.7; font-family: var(--font-mono);">${nodes.length + 1} Available</span>
</div>
<div class="breadcrumb-popover-item ${currentNodeId === 'local' ? 'active' : ''}" onclick="switchPaneNode(${paneIndex}, 'local'); closeBreadcrumbPopovers();" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
<span class="pane-node-dot" style="background: #10b981; width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;"></span>
<div style="min-width: 0; flex: 1;">
<div style="font-weight: 600; font-size: 12px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(getLocalHostDisplayName())}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Local Brum</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<span style="font-size: 9.5px; color: var(--text-dim); font-family: var(--font-mono); background: rgba(255,255,255,0.05); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; white-space: nowrap;">0 ms</span>
${currentNodeId === 'local' ? '<span style="color: var(--accent); font-size: 12px; font-weight: 700; width: 12px; text-align: center;">✓</span>' : '<span style="width: 12px;"></span>'}
</div>
</div>
`;
if (nodes.length > 0) {
html += `<div class="breadcrumb-popover-header" style="margin-top: 4px;">Fleet Control Nodes</div>`;
nodes.forEach(n => {
const isAct = currentNodeId === n.id;
const colorHex = getFleetColorHex(n.color_accent);
let dotColor = colorHex;
if (n.status === 'offline') dotColor = '#ef4444';
else if (n.status === 'unauthorized') dotColor = '#f59e0b';
const latencyStr = typeof n.latency_ms === 'number' ? `${n.latency_ms} ms` : (n.status || 'unknown');
const cleanUrl = (n.endpoint_url || '').replace(/^https?:\/\//, '');
html += `
<div class="breadcrumb-popover-item ${isAct ? 'active' : ''}" onclick="switchPaneNode(${paneIndex}, '${escapeHtml(n.id)}'); closeBreadcrumbPopovers();" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
<span class="pane-node-dot" style="background: ${dotColor}; width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;"></span>
<div style="min-width: 0; flex: 1;">
<div style="font-weight: 600; font-size: 12px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(n.name)}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(cleanUrl)}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<span style="font-size: 9.5px; color: var(--text-dim); font-family: var(--font-mono); background: rgba(255,255,255,0.05); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; white-space: nowrap;">
${escapeHtml(latencyStr)}
</span>
${isAct ? '<span style="color: var(--accent); font-size: 12px; font-weight: 700; width: 12px; text-align: center;">✓</span>' : '<span style="width: 12px;"></span>'}
</div>
</div>
`;
});
}
html += `
<div style="border-top: 1px solid var(--border); margin-top: 4px; padding-top: 4px;">
<div class="breadcrumb-popover-item" onclick="openFleetManagerModal(); closeBreadcrumbPopovers();" style="color: var(--accent); font-weight: 600;">
<i data-lucide="server" style="width: 12px; height: 12px; margin-right: 4px;"></i> Manage Fleet Nodes...
</div>
<div class="breadcrumb-popover-item" onclick="openApiTokensModal(); closeBreadcrumbPopovers();" style="color: var(--text-muted); font-size: 11px;">
<i data-lucide="key" style="width: 12px; height: 12px; margin-right: 4px;"></i> API & Fleet Tokens...
</div>
</div>
`;
popover.innerHTML = html;
document.body.appendChild(popover);
if (window.lucide) {
try { lucide.createIcons({ root: popover }); } catch (_) {}
}
}
function openApiTokensModal() {
if (typeof openAdminModal === 'function') {
openAdminModal();
if (typeof switchAdminTab === 'function') {
switchAdminTab('tokens');
}
} else if (typeof openCreateApiTokenModal === 'function') {
openCreateApiTokenModal();
}
}
// ---------------- 💻 ZERO-INSTALL CLIENT LOCAL VFS (BROWSER FSA API) ----------------
const CLIENT_VFS_DB_NAME = 'brum_client_vfs';
const CLIENT_VFS_DB_VERSION = 1;
const CLIENT_VFS_STORE = 'handles';
function openClientVfsDB() {
return new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
return reject(new Error('IndexedDB not supported'));
}
const request = indexedDB.open(CLIENT_VFS_DB_NAME, CLIENT_VFS_DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(CLIENT_VFS_STORE)) {
db.createObjectStore(CLIENT_VFS_STORE, { keyPath: 'name' });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function saveClientDirectoryHandle(name, handle) {
try {
const db = await openClientVfsDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CLIENT_VFS_STORE, 'readwrite');
const store = tx.objectStore(CLIENT_VFS_STORE);
store.put({ name, handle, lastUsed: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
} catch (err) {
console.warn('Failed to save directory handle in IndexedDB:', err);
}
}
async function getClientDirectoryHandle(name) {
try {
const db = await openClientVfsDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CLIENT_VFS_STORE, 'readonly');
const store = tx.objectStore(CLIENT_VFS_STORE);
const req = store.get(name);
req.onsuccess = () => resolve(req.result ? req.result.handle : null);
req.onerror = (e) => reject(e.target.error);
});
} catch (err) {
console.warn('Failed to get directory handle from IndexedDB:', err);
return null;
}
}
async function getAllStoredClientMounts() {
try {
const db = await openClientVfsDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CLIENT_VFS_STORE, 'readonly');
const store = tx.objectStore(CLIENT_VFS_STORE);
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = (e) => reject(e.target.error);
});
} catch (err) {
return [];
}
}
async function removeClientDirectoryHandle(name) {
try {
const db = await openClientVfsDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CLIENT_VFS_STORE, 'readwrite');
const store = tx.objectStore(CLIENT_VFS_STORE);
store.delete(name);
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
} catch (err) {}
}
const clientVfsState = {
mounts: new Map() // mountName -> { handle, name, lastUsed }
};
function parseClientPath(clientUri) {
if (typeof clientUri !== 'string' || !clientUri.startsWith('client://')) return null;
const raw = clientUri.replace(/^client:\/\//, '');
const slashIdx = raw.indexOf('/');
if (slashIdx === -1) {
return { mountName: raw, subPath: '', segments: [] };
}
const mountName = raw.substring(0, slashIdx);
const subPath = raw.substring(slashIdx + 1);
const segments = subPath.split('/').filter(Boolean);
return { mountName, subPath, segments };
}
async function openClientLocalDirectory(paneIndex = null) {
const targetPane = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
if (!('showDirectoryPicker' in window)) {
showToast('Browser File System Access API is not supported in this browser.', 'warning');
return;
}
try {
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
if (!handle) return;
if (handle.requestPermission) {
const perm = await handle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') {
showToast('Permission not granted for local folder access.', 'warning');
return;
}
}
const mountName = handle.name || 'local-client';
clientVfsState.mounts.set(mountName, { handle, name: mountName, lastUsed: Date.now() });
await saveClientDirectoryHandle(mountName, handle);
const targetPath = `client://${mountName}`;
loadPaneDirectory(targetPane, targetPath);
showToast(`Mounted local client folder "${mountName}"`, 'success');
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error opening client local directory:', err);
showToast(`Local directory error: ${err.message}`, 'error');
}
}
}
async function resolveClientDirectoryHandle(clientUri) {
const parsed = parseClientPath(clientUri);
if (!parsed) throw new Error('Invalid client URI');
let mount = clientVfsState.mounts.get(parsed.mountName);
if (!mount || !mount.handle) {
const storedHandle = await getClientDirectoryHandle(parsed.mountName);
if (storedHandle) {
let hasPerm = true;
if (storedHandle.queryPermission) {
let perm = await storedHandle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted' && storedHandle.requestPermission) {
try {
perm = await storedHandle.requestPermission({ mode: 'readwrite' });
} catch (_) {
hasPerm = false;
}
}
if (perm !== 'granted') hasPerm = false;
}
if (hasPerm) {
mount = { handle: storedHandle, name: parsed.mountName, lastUsed: Date.now() };
clientVfsState.mounts.set(parsed.mountName, mount);
}
}
}
if (!mount || !mount.handle) {
throw new Error(`Local client folder "${parsed.mountName}" is not active or permission was revoked. Please mount it again.`);
}
let curDir = mount.handle;
for (const seg of parsed.segments) {
curDir = await curDir.getDirectoryHandle(seg);
}
return curDir;
}
async function resolveClientFileHandle(clientUri, create = false) {
const parsed = parseClientPath(clientUri);
if (!parsed || parsed.segments.length === 0) throw new Error('Invalid client file URI');
const fileName = parsed.segments[parsed.segments.length - 1];
const dirSegments = parsed.segments.slice(0, -1);
let mount = clientVfsState.mounts.get(parsed.mountName);
if (!mount || !mount.handle) {
const storedHandle = await getClientDirectoryHandle(parsed.mountName);
if (storedHandle) {
let hasPerm = true;
if (storedHandle.queryPermission) {
let perm = await storedHandle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted' && storedHandle.requestPermission) {
try {
perm = await storedHandle.requestPermission({ mode: 'readwrite' });
} catch (_) {
hasPerm = false;
}
}
if (perm !== 'granted') hasPerm = false;
}
if (hasPerm) {
mount = { handle: storedHandle, name: parsed.mountName, lastUsed: Date.now() };
clientVfsState.mounts.set(parsed.mountName, mount);
}
}
}
if (!mount || !mount.handle) {
throw new Error(`Local client folder "${parsed.mountName}" is not active.`);
}
let curDir = mount.handle;
for (const seg of dirSegments) {
curDir = await curDir.getDirectoryHandle(seg, { create });
}
return await curDir.getFileHandle(fileName, { create });
}
async function scanClientDirectoryBranch(dirHandle, basePath, maxLimit = 5000) {
const allEntries = [];
async function traverse(currentHandle, currentPath) {
if (allEntries.length >= maxLimit) return;
for await (const [name, handle] of currentHandle.entries()) {
if (allEntries.length >= maxLimit) return;
const itemPath = currentPath.endsWith('/') ? `${currentPath}${name}` : `${currentPath}/${name}`;
const isDir = handle.kind === 'directory';
let size = 0;
let modified = Math.floor(Date.now() / 1000);
if (!isDir) {
try {
const file = await handle.getFile();
size = file.size;
modified = Math.floor(file.lastModified / 1000);
} catch (_) {}
}
allEntries.push({
name,
path: itemPath,
is_dir: isDir,
is_symlink: false,
is_archive: isArchiveFile(name),
size,
modified,
mode: isDir ? 16877 : 33188,
readonly: false,
owner: 'client',
group: 'client',
extension: isDir ? null : (name.split('.').pop() || null)
});
if (isDir) {
try {
await traverse(handle, itemPath);
} catch (_) {}
}
}
}
await traverse(dirHandle, basePath);
return allEntries;
}
async function loadClientLocalDirectory(paneIndex, targetPath, pushHistory = true, selectItemName = null, retainBranch = false, isSyncNav = false) {
const pane = App.panes[paneIndex];
if (!pane) return;
const prevPath = pane.path;
const parsed = parseClientPath(targetPath);
if (!parsed) return;
if (pane._abortController) {
try { pane._abortController.abort(); } catch (_) {}
pane._abortController = null;
}
if (!retainBranch && pane.path && targetPath !== pane.path && pane.isBranchView) {
pane.isBranchView = false;
pane.isBranchTruncated = false;
}
pane.path = targetPath;
if (!selectItemName) {
pane.selected.clear();
}
localStorage.setItem(`cd_pane_path_${paneIndex}`, targetPath);
if (pushHistory && typeof window !== 'undefined' && window.history && typeof window.history.pushState === 'function') {
try {
window.history.pushState({ type: 'dir', paneIndex, path: targetPath, nodeId: 'local' }, '', '');
} catch (_) {}
}
if (pane.isBranchView) {
renderPaneBranchLoading(paneIndex);
}
try {
const dirHandle = await resolveClientDirectoryHandle(targetPath);
let entries = [];
let totalSize = 0;
if (pane.isBranchView) {
entries = await scanClientDirectoryBranch(dirHandle, targetPath, pane.branchMaxLimit || 5000);
totalSize = entries.reduce((acc, e) => acc + (e.size || 0), 0);
} else {
for await (const [name, handle] of dirHandle.entries()) {
if (!pane.showHidden && name.startsWith('.')) continue;
const isDir = handle.kind === 'directory';
let size = 0;
let modified = Math.floor(Date.now() / 1000);
let isArchive = false;
if (!isDir) {
try {
const file = await handle.getFile();
size = file.size;
modified = Math.floor(file.lastModified / 1000);
totalSize += size;
isArchive = isArchiveFile(name);
} catch (_) {}
}
const itemPath = targetPath.endsWith('/') ? `${targetPath}${name}` : `${targetPath}/${name}`;
entries.push({
name,
path: itemPath,
is_dir: isDir,
is_symlink: false,
is_archive: isArchive,
size,
modified,
mode: isDir ? 16877 : 33188,
readonly: false,
owner: 'client',
group: 'client',
extension: isDir ? null : (name.split('.').pop() || null)
});
}
entries.sort((a, b) => {
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
}
let parentPath = null;
if (parsed.segments.length > 0) {
const parentSegments = parsed.segments.slice(0, -1);
parentPath = parentSegments.length > 0 ? `client://${parsed.mountName}/${parentSegments.join('/')}` : `client://${parsed.mountName}`;
}
pane.path = targetPath;
pane.parentPath = parentPath;
pane.entries = entries;
pane.totalSize = totalSize;
pane.isBranchTruncated = false;
if (selectItemName && pane.entries) {
pane.selected.clear();
const cleanName = selectItemName.split('/').pop();
const match = pane.entries.find(e => e.name === cleanName || e.name === selectItemName || e.path === selectItemName || e.path.endsWith('/' + cleanName));
if (match) {
pane.selected.add(match.path);
const idx = pane.entries.indexOf(match);
if (idx !== -1) pane.cursorIndex = idx;
}
}
try {
renderPaneBreadcrumbs(paneIndex, pane.path);
renderPaneTabs(paneIndex);
} catch (bErr) {
console.error('Breadcrumb/tab render error:', bErr);
}
if (pane.dockedTool) {
renderDockedPaneTool(paneIndex);
} else {
renderPaneTable(paneIndex);
const activeTab = pane.tabs ? pane.tabs[pane.activeTabIndex] : null;
if (activeTab && activeTab.scrollTop) {
const mainView = document.querySelector(`#pane-${paneIndex} .pane-main-view`);
if (mainView) mainView.scrollTop = activeTab.scrollTop;
}
}
try {
updatePaneFooter(paneIndex, {
current_path: targetPath,
parent_path: parentPath,
entries,
total_size: totalSize,
free_space: null,
total_space: null,
read_only: false
});
if (paneIndex === App.activePaneIndex) {
updateBranchToggleState();
}
if (App.syncNav && !isSyncNav && !isSyncNavigating && prevPath && targetPath && prevPath !== targetPath) {
handlePaneNavSync(paneIndex, prevPath, targetPath);
}
} catch (fErr) {}
} catch (err) {
console.error(`Failed to load client directory ${targetPath}:`, err);
pane.path = prevPath;
if (prevPath) localStorage.setItem(`cd_pane_path_${paneIndex}`, prevPath);
if (!isSyncNav) {
showToast(`Client local folder: ${err.message}`, 'warning');
const userHome = getUserDefaultHomeDir() || '/';
loadPaneDirectory(paneIndex, userHome, false);
}
}
}
async function deleteClientLocalItem(itemPath) {
const parsed = parseClientPath(itemPath);
if (!parsed || parsed.segments.length === 0) throw new Error('Cannot delete client mount root');
const itemName = parsed.segments[parsed.segments.length - 1];
const parentSegments = parsed.segments.slice(0, -1);
const parentUri = parentSegments.length > 0 ? `client://${parsed.mountName}/${parentSegments.join('/')}` : `client://${parsed.mountName}`;
const parentDir = await resolveClientDirectoryHandle(parentUri);
await parentDir.removeEntry(itemName, { recursive: true });
}
async function createClientLocalDirectory(parentPath, newFolderName) {
const parentDir = await resolveClientDirectoryHandle(parentPath);
await parentDir.getDirectoryHandle(newFolderName, { create: true });
}
async function renameClientLocalItem(itemPath, newName) {
const parsed = parseClientPath(itemPath);
if (!parsed || parsed.segments.length === 0) throw new Error('Cannot rename client mount root');
const oldName = parsed.segments[parsed.segments.length - 1];
const parentSegments = parsed.segments.slice(0, -1);
const parentUri = parentSegments.length > 0 ? `client://${parsed.mountName}/${parentSegments.join('/')}` : `client://${parsed.mountName}`;
const parentDir = await resolveClientDirectoryHandle(parentUri);
try {
let targetHandle;
try {
targetHandle = await parentDir.getFileHandle(oldName);
} catch (_) {
targetHandle = await parentDir.getDirectoryHandle(oldName);
}
if (targetHandle && typeof targetHandle.move === 'function') {
await targetHandle.move(newName);
return;
}
} catch (_) {}
const oldFileHandle = await parentDir.getFileHandle(oldName);
const file = await oldFileHandle.getFile();
const newFileHandle = await parentDir.getFileHandle(newName, { create: true });
const writable = await newFileHandle.createWritable();
await writable.write(file);
await writable.close();
await parentDir.removeEntry(oldName);
}
async function executeClientLocalTransfer(action, sources, destination, destIdx, srcIdx) {
const isSrcClient = sources.some(s => s.startsWith('client://'));
const isDestClient = destination.startsWith('client://');
const srcNode = getPaneNode(srcIdx);
const destNode = getPaneNode(destIdx);
const srcLabel = isSrcClient ? 'Local Browser (Client)' : srcNode.name;
const destLabel = isDestClient ? 'Local Browser (Client)' : destNode.name;
const totalItems = sources.length;
showToast(`Starting ${action === 'move' ? 'Move' : 'Copy'} (${srcLabel} ➔ ${destLabel}): ${totalItems} item(s)...`, 'info');
const taskId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const clientTask = {
id: taskId,
name: `Client VFS ${action === 'move' ? 'Move' : 'Copy'} (${srcLabel} ➔ ${destLabel})`,
action: action,
source: sources[0] + (sources.length > 1 ? ` (+${sources.length - 1} more)` : ''),
destination: `${destLabel}:${destination}`,
total_files: sources.length,
files_processed: 0,
total_bytes: 0,
bytes_processed: 0,
current_file: '',
current_file_bytes: 0,
current_file_total_bytes: 0,
speed_bytes_per_sec: 0,
status: 'running',
paranoid: !!App.paranoidMode,
verified_files: 0,
last_hash: null,
log_entries: [
`[${new Date().toLocaleTimeString()}] Starting Client VFS ${action} (${srcLabel} ➔ ${destLabel})`
],
started_at: Math.floor(Date.now() / 1000),
created_at: new Date().toISOString()
};
lastKnownTasksList.unshift(clientTask);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
if (App.autoOpenTasks && !App.taskManagerVisible) {
openFloatingTaskManager();
}
requestTasksFastBurst(6000);
const pill = document.getElementById('tasks-pill');
const pillText = document.getElementById('tasks-pill-text');
if (pill && pillText) {
pill.style.display = 'flex';
pillText.textContent = `Transferring ${action} (${srcLabel} ➔ ${destLabel})...`;
}
let successCount = 0;
let failCount = 0;
let totalBatchBytesDoneBefore = 0;
for (let i = 0; i < sources.length; i++) {
const srcPath = sources[i];
const fileName = srcPath.split('/').filter(Boolean).pop() || 'transfer_file';
clientTask.current_file = fileName;
clientTask.current_file_bytes = 0;
clientTask.current_file_total_bytes = 0;
if (pillText) pillText.textContent = `[${i + 1}/${totalItems}] ${fileName}...`;
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
try {
if (clientTask.status === 'cancelled') {
throw new Error('Transfer cancelled by user');
}
while (clientTask.status === 'paused') {
await new Promise(r => setTimeout(r, 120));
if (clientTask.status === 'cancelled') {
throw new Error('Transfer cancelled by user');
}
}
let blobOrFile = null;
if (!isSrcClient && isDestClient) {
// Server -> Client
const srcEndpoint = getPaneEndpoint(srcIdx);
const srcHeaders = getPaneAuthHeaders(srcIdx);
const dlUrl = `${srcEndpoint}/api/fs/download?path=${encodeURIComponent(resolveAuthUri(srcPath))}`;
const downloadResp = await fetch(dlUrl, { method: 'GET', headers: srcHeaders });
if (!downloadResp.ok) throw new Error(`Download failed (${downloadResp.status})`);
const contentLength = parseInt(downloadResp.headers.get('content-length') || '0', 10);
if (contentLength > 0) {
clientTask.current_file_total_bytes = contentLength;
if (clientTask.total_bytes === 0 || clientTask.total_bytes < totalBatchBytesDoneBefore + contentLength) {
clientTask.total_bytes = totalBatchBytesDoneBefore + contentLength;
}
}
const reader = downloadResp.body.getReader();
const chunks = [];
let receivedBytes = 0;
const downloadStart = performance.now();
let lastDlProgress = performance.now();
while (true) {
if (clientTask.status === 'cancelled') {
try { reader.cancel(); } catch(_) {}
throw new Error('Transfer cancelled by user');
}
while (clientTask.status === 'paused') {
await new Promise(r => setTimeout(r, 120));
if (clientTask.status === 'cancelled') {
try { reader.cancel(); } catch(_) {}
throw new Error('Transfer cancelled by user');
}
}
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedBytes += value.length;
clientTask.current_file_bytes = receivedBytes;
const now = performance.now();
if (now - lastDlProgress >= 35) {
lastDlProgress = now;
const elapsedSec = (now - downloadStart) / 1000;
if (elapsedSec > 0.05) {
clientTask.speed_bytes_per_sec = Math.round(receivedBytes / elapsedSec);
}
clientTask.bytes_processed = totalBatchBytesDoneBefore + receivedBytes;
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
}
blobOrFile = new Blob(chunks);
clientTask.current_file_total_bytes = blobOrFile.size;
clientTask.total_bytes = Math.max(clientTask.total_bytes, totalBatchBytesDoneBefore + blobOrFile.size);
const destFileUri = destination.endsWith('/') ? `${destination}${fileName}` : `${destination}/${fileName}`;
const destHandle = await resolveClientFileHandle(destFileUri, true);
const writable = await destHandle.createWritable();
await writable.write(blobOrFile);
await writable.close();
if (action === 'move') {
await fetch(`${srcEndpoint}/api/fs/delete`, {
method: 'POST',
headers: getPaneAuthHeaders(srcIdx, { 'Content-Type': 'application/json' }),
body: JSON.stringify({ paths: [resolveAuthUri(srcPath)], use_trash: false })
});
}
} else if (isSrcClient && !isDestClient) {
// Client -> Server
const fileHandle = await resolveClientFileHandle(srcPath, false);
blobOrFile = await fileHandle.getFile();
clientTask.current_file_total_bytes = blobOrFile.size;
clientTask.total_bytes = Math.max(clientTask.total_bytes, totalBatchBytesDoneBefore + blobOrFile.size);
const destEndpoint = getPaneEndpoint(destIdx);
const destHeaders = getPaneAuthHeaders(destIdx);
const uploadStart = performance.now();
let lastUpProgress = performance.now();
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const uploadUrl = `${destEndpoint}/api/fs/upload?destination=${encodeURIComponent(resolveAuthUri(destination))}&no_task=1`;
xhr.open('POST', uploadUrl);
for (const h in destHeaders) {
xhr.setRequestHeader(h, destHeaders[h]);
}
xhr.upload.onprogress = (e) => {
if (clientTask.status === 'cancelled') {
xhr.abort();
reject(new Error('Transfer cancelled by user'));
return;
}
if (e.lengthComputable) {
clientTask.current_file_bytes = e.loaded;
clientTask.current_file_total_bytes = e.total;
const now = performance.now();
if (now - lastUpProgress >= 35) {
lastUpProgress = now;
const elapsedSec = (now - uploadStart) / 1000;
if (elapsedSec > 0.05) {
clientTask.speed_bytes_per_sec = Math.round(e.loaded / elapsedSec);
}
clientTask.bytes_processed = totalBatchBytesDoneBefore + e.loaded;
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed (${xhr.status}): ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network upload error to server'));
xhr.onabort = () => reject(new Error('Upload aborted'));
const formData = new FormData();
formData.append('files', blobOrFile, fileName);
xhr.send(formData);
});
if (action === 'move') {
await deleteClientLocalItem(srcPath);
}
} else if (isSrcClient && isDestClient) {
// Client -> Client
const srcFileHandle = await resolveClientFileHandle(srcPath, false);
blobOrFile = await srcFileHandle.getFile();
clientTask.current_file_total_bytes = blobOrFile.size;
clientTask.total_bytes = Math.max(clientTask.total_bytes, totalBatchBytesDoneBefore + blobOrFile.size);
const destFileUri = destination.endsWith('/') ? `${destination}${fileName}` : `${destination}/${fileName}`;
const destFileHandle = await resolveClientFileHandle(destFileUri, true);
const writable = await destFileHandle.createWritable();
await writable.write(blobOrFile);
await writable.close();
if (action === 'move') {
await deleteClientLocalItem(srcPath);
}
}
// Hash calculation
const computedHash = blobOrFile ? await computeSha256Hex(blobOrFile) : null;
if (computedHash) {
clientTask.last_hash = `SHA-256 Match: ${computedHash}`;
clientTask.verified_files = (clientTask.verified_files || 0) + 1;
clientTask.paranoid = true;
}
const fileSize = blobOrFile ? blobOrFile.size : 0;
totalBatchBytesDoneBefore += fileSize;
clientTask.files_processed++;
clientTask.bytes_processed = totalBatchBytesDoneBefore;
const hashLog = clientTask.last_hash ? ` [${clientTask.last_hash}]` : '';
clientTask.log_entries.push(`[${new Date().toLocaleTimeString()}] ✓ Transferred ${fileName} (${formatBytes(fileSize)})${hashLog}`);
successCount++;
} catch (err) {
console.error(`Transfer error for ${fileName}:`, err);
failCount++;
clientTask.log_entries.push(`[${new Date().toLocaleTimeString()}] ❌ Error on ${fileName}: ${err.message || err}`);
showToast(`Failed to transfer ${fileName}: ${err.message}`, 'error');
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
clientTask.status = failCount > 0 ? (successCount > 0 ? 'completed' : 'failed') : 'completed';
clientTask.speed_bytes_per_sec = 0;
clientTask.finished_at = Math.floor(Date.now() / 1000);
clientTask.bytes_processed = clientTask.total_bytes;
clientTask.log_entries.push(`[${new Date().toLocaleTimeString()}] Client VFS transfer finished: ${successCount} succeeded, ${failCount} failed`);
if (action === 'move') {
const srcPane = App.panes[srcIdx];
if (srcPane && srcPane.selected) srcPane.selected.clear();
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
refreshAllPanes();
showToast(`${action === 'move' ? 'Moved' : 'Copied'} ${successCount} item(s)${failCount > 0 ? ` (${failCount} failed)` : ''}`, successCount > 0 ? 'success' : 'error');
}
function computeJsLineDiff(textL, textR) {
const linesL = (textL || '').split('\n');
const linesR = (textR || '').split('\n');
const n = linesL.length;
const m = linesR.length;
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
if (linesL[i] === linesR[j]) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
const result = [];
let i = n, j = m;
let additions = 0, deletions = 0;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && linesL[i - 1] === linesR[j - 1]) {
result.unshift({ tag: 'equal', content: linesL[i - 1], line_num_left: i, line_num_right: j });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ tag: 'insert', content: linesR[j - 1], line_num_left: null, line_num_right: j });
additions++;
j--;
} else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
result.unshift({ tag: 'delete', content: linesL[i - 1], line_num_left: i, line_num_right: null });
deletions++;
i--;
}
}
return {
file_left: '',
file_right: '',
additions,
deletions,
lines: result
};
}
async function loadPaneDirectory(paneIndex, targetPath, pushHistory = true, selectItemName = null, retainBranch = false, isSyncNav = false, backgroundRevalidate = false) {
const pane = App.panes[paneIndex];
if (!pane) return;
const prevPath = pane.path;
if (typeof targetPath === 'string' && targetPath.startsWith('client://')) {
return loadClientLocalDirectory(paneIndex, targetPath, pushHistory, selectItemName, retainBranch, isSyncNav);
}
const isLocal = !pane.nodeId || pane.nodeId === 'local';
const lowerTarget = (typeof targetPath === 'string') ? targetPath.trim().toLowerCase() : '';
const isTrashAlias = lowerTarget === 'trash://' || lowerTarget === 'trash:' || lowerTarget === 'trash' || lowerTarget === 'recycle bin' || lowerTarget === 'recyclebin' || lowerTarget === 'recycle_bin' || lowerTarget === 'shell:recyclebinfolder';
if (isTrashAlias) {
targetPath = 'trash://';
} else if (targetPath === '~' || (typeof targetPath === 'string' && (targetPath.startsWith('~/') || targetPath.startsWith('~\\')))) {
const userHome = isLocal ? getUserDefaultHomeDir() : '/';
const resolvedHome = (userHome && userHome !== '~') ? userHome : '/';
const isWin = /^[a-zA-Z]:/.test(resolvedHome) || resolvedHome.includes('\\');
const sub = targetPath === '~' ? '' : targetPath.substring(2);
if (isWin) {
const cleanHome = resolvedHome.replace(/\//g, '\\').replace(/\\+$/, '');
const cleanSub = sub.replace(/\//g, '\\').replace(/^\\+/, '');
targetPath = cleanSub ? `${cleanHome}\\${cleanSub}` : cleanHome;
} else {
targetPath = targetPath === '~' ? resolvedHome : (resolvedHome.endsWith('/') ? resolvedHome : resolvedHome + '/') + sub.replace(/^\/+/, '');
}
}
const cleanPath = sanitizeCredentials(targetPath);
const authUrl = resolveAuthUri(targetPath);
// Abort any ongoing directory fetch for this pane
if (pane._abortController) {
try {
pane._abortController.abort();
} catch (_) {}
pane._abortController = null;
}
// If navigating to a different directory without explicitly retaining branch mode, exit branch mode
if (!retainBranch && pane.path && cleanPath !== pane.path && pane.isBranchView) {
pane.isBranchView = false;
pane.isBranchTruncated = false;
}
pane.path = cleanPath;
if (!selectItemName) {
pane.selected.clear();
}
localStorage.setItem(`cd_pane_path_${paneIndex}`, cleanPath);
if (pushHistory && typeof window !== 'undefined' && window.history && typeof window.history.pushState === 'function') {
try {
window.history.pushState({ type: 'dir', paneIndex, path: cleanPath, nodeId: pane.nodeId || 'local' }, '', '');
} catch (_) {}
}
// ⚡ Optimistic Cache Hydration: Render cached directory snapshot immediately on frame 0
if (!pane.isBranchView) {
const cached = getDirectoryCache(pane.nodeId, cleanPath);
if (cached && cached.entries && Array.isArray(cached.entries)) {
pane.entries = cached.entries;
pane.parentPath = cached.parent_path || null;
pane.totalSize = cached.total_size || 0;
pane.isBranchTruncated = !!cached.is_truncated;
pane.branchMaxLimit = cached.max_limit || 5000;
try {
renderPaneBreadcrumbs(paneIndex, pane.path);
renderPaneTabs(paneIndex);
if (!pane.dockedTool) {
renderPaneTable(paneIndex);
}
} catch (_) {}
}
}
pane._abortController = new AbortController();
if (pane.isBranchView) {
renderPaneBranchLoading(paneIndex);
}
try {
const endpoint = getPaneEndpoint(paneIndex);
const headers = getPaneAuthHeaders(paneIndex);
const flatParam = pane.isBranchView ? '&flat=true' : '';
const winNativeParam = (typeof App.windowsNativeOps === 'boolean') ? `&windows_native_ops=${App.windowsNativeOps}` : '';
const customTrashParam = App.customTrashDir ? `&custom_trash_dir=${encodeURIComponent(App.customTrashDir)}` : '';
const url = `${endpoint}/api/fs/list?path=${encodeURIComponent(authUrl)}&show_hidden=${pane.showHidden}${flatParam}${winNativeParam}${customTrashParam}`;
const resp = await fetch(url, {
headers,
signal: pane._abortController.signal
});
if (!resp.ok) {
const errText = sanitizeCredentials(await resp.text());
console.warn(`Failed to load ${cleanPath}:`, errText);
pane.path = prevPath;
if (prevPath) {
localStorage.setItem(`cd_pane_path_${paneIndex}`, prevPath);
}
if (isLocal) {
const userHome = getUserDefaultHomeDir();
if ((cleanPath === '/' || resp.status === 403) && userHome && userHome !== '/' && userHome !== cleanPath) {
console.info(`Auto-redirecting pane ${paneIndex} from ${cleanPath} to user home: ${userHome}`);
loadPaneDirectory(paneIndex, userHome, false);
return;
}
}
if (!isSyncNav && !backgroundRevalidate) {
showToast(`Failed to load directory: ${errText}`, 'error');
}
return;
}
const data = await resp.json();
const newPath = sanitizeCredentials(data.current_path);
const newParent = data.parent_path ? sanitizeCredentials(data.parent_path) : null;
const newEntries = (data.entries || []).map(e => ({
...e,
path: sanitizeCredentials(e.path)
}));
const newTotalSize = data.total_size;
// Cache updated directory listing (except flat recursive branches)
if (!pane.isBranchView) {
setDirectoryCache(pane.nodeId, newPath, {
current_path: newPath,
parent_path: newParent,
entries: newEntries,
total_size: newTotalSize,
is_truncated: !!data.is_truncated,
max_limit: data.max_limit || 5000
});
}
// Determine if DOM re-render is necessary during background revalidation
let needsRerender = true;
if (backgroundRevalidate && pane.entries && pane.entries.length === newEntries.length && pane.path === newPath) {
let isIdentical = true;
for (let i = 0; i < newEntries.length; i++) {
const oldE = pane.entries[i];
const newE = newEntries[i];
if (!oldE || oldE.name !== newE.name || oldE.size !== newE.size || oldE.modified !== newE.modified || oldE.is_dir !== newE.is_dir) {
isIdentical = false;
break;
}
}
if (isIdentical) {
needsRerender = false;
}
}
pane.path = newPath;
pane.parentPath = newParent;
pane.entries = newEntries;
pane.totalSize = newTotalSize;
pane.isBranchTruncated = !!data.is_truncated;
pane.branchMaxLimit = data.max_limit || 5000;
if (pane.isBranchTruncated && pane.isBranchView) {
showToast(`⚠️ Branch view capped at ${pane.branchMaxLimit} items for performance`, 'warning');
}
if (selectItemName && pane.entries) {
pane.selected.clear();
const cleanName = selectItemName.split('/').pop();
const match = pane.entries.find(e => e.name === cleanName || e.name === selectItemName || e.path === selectItemName || e.path.endsWith('/' + cleanName));
if (match) {
pane.selected.add(match.path);
const idx = pane.entries.indexOf(match);
if (idx !== -1) pane.cursorIndex = idx;
}
}
try {
renderPaneBreadcrumbs(paneIndex, pane.path);
renderPaneTabs(paneIndex);
} catch (bErr) {
console.error('Breadcrumb/tab render error:', bErr);
}
if (pane.dockedTool) {
renderDockedPaneTool(paneIndex);
} else if (needsRerender) {
renderPaneTable(paneIndex);
const activeTab = pane.tabs ? pane.tabs[pane.activeTabIndex] : null;
if (activeTab && activeTab.scrollTop) {
const mainView = document.querySelector(`#pane-${paneIndex} .pane-main-view`);
if (mainView) {
mainView.scrollTop = activeTab.scrollTop;
}
}
}
try {
updatePaneFooter(paneIndex, data);
fetchGitStatusForPane(paneIndex, pane.path);
if (paneIndex === App.activePaneIndex) {
syncTreeActiveNode(pane.path);
updateBranchToggleState();
}
syncPaneTreeActiveNode(paneIndex, pane.path);
if (App.syncNav && !isSyncNav && !isSyncNavigating && prevPath && pane.path && prevPath !== pane.path) {
handlePaneNavSync(paneIndex, prevPath, pane.path);
}
} catch (fErr) {
console.warn('Footer/tree sync error:', fErr);
}
} catch (e) {
if (e.name === 'AbortError') {
return;
}
console.error('Directory load error:', e);
pane.path = prevPath;
if (prevPath) localStorage.setItem(`cd_pane_path_${paneIndex}`, prevPath);
if (!isSyncNav && !backgroundRevalidate) {
showToast(`Directory load error: ${e.message}`, 'error');
}
} finally {
pane._abortController = null;
}
}
function renderPaneBreadcrumbs(paneIndex, pathStr) {
const pane = App.panes[paneIndex];
const container = document.getElementById(`pane-crumbs-${paneIndex}`);
if (!container) return;
container.innerHTML = '';
pathStr = pathStr || '/';
// If pane is bound to a remote fleet node, prepend node chip indicator
const isRemoteNode = pane && pane.nodeId && pane.nodeId !== 'local';
if (isRemoteNode) {
const node = getPaneNode(paneIndex);
const colorHex = getFleetColorHex(node.color_accent);
const nodeChip = document.createElement('button');
nodeChip.className = 'btn btn-xs pane-node-chip';
nodeChip.style.borderColor = `${colorHex}55`;
nodeChip.style.background = `${colorHex}18`;
nodeChip.style.color = colorHex;
nodeChip.style.fontWeight = '600';
nodeChip.style.display = 'inline-flex';
nodeChip.style.alignItems = 'center';
nodeChip.style.gap = '5px';
nodeChip.title = `Connected to Fleet Node: ${node.name} (${node.endpoint_url || ''}). Click to switch node.`;
nodeChip.innerHTML = `<i data-lucide="server" style="width: 11px; height: 11px; color: ${colorHex};"></i> <span>${escapeHtml(node.name)}</span>`;
nodeChip.onclick = (e) => {
e.stopPropagation();
openPaneFavoritesMenu(e, paneIndex);
};
container.appendChild(nodeChip);
const disconnectBtn = document.createElement('button');
disconnectBtn.className = 'btn btn-xs pane-disconnect-chip';
disconnectBtn.style.marginRight = '4px';
disconnectBtn.style.padding = '2px 5px';
disconnectBtn.style.borderRadius = '4px';
disconnectBtn.style.background = 'rgba(239, 68, 68, 0.15)';
disconnectBtn.style.border = '1px solid rgba(239, 68, 68, 0.4)';
disconnectBtn.style.color = 'var(--danger, #ef4444)';
disconnectBtn.style.display = 'inline-flex';
disconnectBtn.style.alignItems = 'center';
disconnectBtn.style.cursor = 'pointer';
disconnectBtn.title = `Disconnect from ${node.name} and return to Local Host`;
disconnectBtn.innerHTML = '<i data-lucide="unplug" style="width: 10px; height: 10px;"></i>';
disconnectBtn.onclick = (e) => {
e.stopPropagation();
switchPaneNode(paneIndex, 'local');
};
container.appendChild(disconnectBtn);
}
if (pane && pane.isBranchView) {
const branchBadge = document.createElement('span');
branchBadge.className = 'branch-view-badge';
branchBadge.innerHTML = '<i data-lucide="git-branch" style="width:11px; height:11px;"></i> Flat Branch View';
container.appendChild(branchBadge);
// Clickable ancestor breadcrumb navigation
const parts = pathStr.split('/').filter(Boolean);
let accum = '';
const rootCrumb = document.createElement('span');
rootCrumb.className = 'crumb';
rootCrumb.textContent = '/';
rootCrumb.title = 'Exit Flat Branch View and go to /';
rootCrumb.onclick = (e) => { e.stopPropagation(); exitBranchView(paneIndex, '/'); };
container.appendChild(rootCrumb);
parts.forEach((p) => {
accum += '/' + p;
const target = accum;
const sep = document.createElement('span');
sep.className = 'crumb-sep';
sep.textContent = '/';
container.appendChild(sep);
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = p;
c.title = `Exit Flat Branch View and go to ${target}`;
c.onclick = (e) => { e.stopPropagation(); exitBranchView(paneIndex, target); };
container.appendChild(c);
});
const exitBtn = document.createElement('button');
exitBtn.className = 'btn btn-xs';
exitBtn.style.marginLeft = 'auto';
exitBtn.style.padding = '1px 7px';
exitBtn.style.color = 'var(--accent)';
exitBtn.style.borderColor = 'rgba(245, 158, 11, 0.4)';
exitBtn.innerHTML = '<i data-lucide="x" style="width: 10px; height: 10px; margin-right: 3px;"></i> Exit Branch';
exitBtn.title = 'Exit Flat Branch View (Ctrl+B)';
exitBtn.onclick = (e) => { e.stopPropagation(); toggleBranchView(paneIndex); };
container.appendChild(exitBtn);
if (window.lucide) lucide.createIcons();
return;
}
if (pane && pane.isVirtual) {
const virtBadge = document.createElement('span');
virtBadge.className = 'virtual-pane-badge';
virtBadge.innerHTML = `<i data-lucide="search" style="width:11px; height:11px;"></i> ${escapeHtml(pane.virtualTitle || 'Search Results')}`;
container.appendChild(virtBadge);
const exitBtn = document.createElement('button');
exitBtn.className = 'btn btn-xs';
exitBtn.style.marginLeft = 'auto';
exitBtn.style.padding = '1px 6px';
exitBtn.textContent = '✕ Exit Virtual';
exitBtn.onclick = (e) => { e.stopPropagation(); exitVirtualPane(paneIndex); };
container.appendChild(exitBtn);
if (window.lucide) lucide.createIcons();
return;
}
if (pathStr.startsWith('vault://')) {
const raw = pathStr.replace('vault://', '');
const [vaultFile, sub] = raw.split('#');
const lockVaultBtn = document.createElement('button');
lockVaultBtn.className = 'btn btn-xs pane-disconnect-chip';
lockVaultBtn.style.marginRight = '4px';
lockVaultBtn.style.padding = '2px 6px';
lockVaultBtn.style.borderRadius = '4px';
lockVaultBtn.style.background = 'rgba(239, 68, 68, 0.15)';
lockVaultBtn.style.border = '1px solid rgba(239, 68, 68, 0.4)';
lockVaultBtn.style.color = 'var(--danger, #ef4444)';
lockVaultBtn.style.display = 'inline-flex';
lockVaultBtn.style.alignItems = 'center';
lockVaultBtn.style.justifyContent = 'center';
lockVaultBtn.style.cursor = 'pointer';
lockVaultBtn.style.flexShrink = '0';
lockVaultBtn.title = 'Lock vault and purge master key from memory';
lockVaultBtn.innerHTML = '<i data-lucide="lock" style="width:11px; height:11px; margin-right: 3px;"></i> Lock';
lockVaultBtn.onclick = (e) => { e.stopPropagation(); disconnectPaneRemote(paneIndex); };
container.appendChild(lockVaultBtn);
const rootCrumb = document.createElement('span');
rootCrumb.className = 'crumb';
rootCrumb.style.color = 'var(--accent)';
rootCrumb.style.fontWeight = '700';
rootCrumb.textContent = '🔒 ' + (vaultFile.split('/').pop() || 'vault');
rootCrumb.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, `vault://${vaultFile}#`); };
container.appendChild(rootCrumb);
if (sub) {
const subParts = sub.split('/').filter(Boolean);
let buildSub = '';
subParts.forEach(part => {
const sep = document.createElement('span');
sep.className = 'crumb-sep';
sep.textContent = '/';
container.appendChild(sep);
buildSub += (buildSub ? '/' : '') + part;
const target = `vault://${vaultFile}#${buildSub}`;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
}
if (window.lucide) lucide.createIcons();
return;
}
if (pathStr.startsWith('archive://')) {
const raw = pathStr.replace('archive://', '');
const [arch, sub] = raw.split('#');
const closeArchIconBtn = document.createElement('button');
closeArchIconBtn.className = 'btn btn-xs pane-disconnect-chip';
closeArchIconBtn.style.marginRight = '4px';
closeArchIconBtn.style.padding = '2px 6px';
closeArchIconBtn.style.borderRadius = '4px';
closeArchIconBtn.style.background = 'rgba(239, 68, 68, 0.15)';
closeArchIconBtn.style.border = '1px solid rgba(239, 68, 68, 0.4)';
closeArchIconBtn.style.color = 'var(--danger, #ef4444)';
closeArchIconBtn.style.display = 'inline-flex';
closeArchIconBtn.style.alignItems = 'center';
closeArchIconBtn.style.justifyContent = 'center';
closeArchIconBtn.style.cursor = 'pointer';
closeArchIconBtn.style.flexShrink = '0';
closeArchIconBtn.title = 'Close archive and return to parent folder';
closeArchIconBtn.innerHTML = '<i data-lucide="x" style="width:11px; height:11px;"></i>';
closeArchIconBtn.onclick = (e) => { e.stopPropagation(); disconnectPaneRemote(paneIndex); };
container.appendChild(closeArchIconBtn);
const rootCrumb = document.createElement('span');
rootCrumb.className = 'crumb';
rootCrumb.textContent = '📦 ' + (arch.split('/').pop() || 'archive');
rootCrumb.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, `archive://${arch}#`); };
container.appendChild(rootCrumb);
if (sub) {
const subParts = sub.split('/').filter(Boolean);
let buildSub = '';
subParts.forEach(part => {
const sep = document.createElement('span');
sep.className = 'crumb-sep';
sep.textContent = '/';
container.appendChild(sep);
buildSub += (buildSub ? '/' : '') + part;
const target = `archive://${arch}#${buildSub}`;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
}
if (window.lucide) lucide.createIcons();
return;
}
if (pathStr.startsWith('client://')) {
const parsed = parseClientPath(pathStr);
const mountName = parsed ? parsed.mountName : 'local';
const subpath = parsed ? parsed.subPath : '';
const disIconBtn = document.createElement('button');
disIconBtn.className = 'btn btn-xs pane-disconnect-chip';
disIconBtn.style.marginRight = '4px';
disIconBtn.style.padding = '2px 6px';
disIconBtn.style.borderRadius = '4px';
disIconBtn.style.background = 'rgba(239, 68, 68, 0.15)';
disIconBtn.style.border = '1px solid rgba(239, 68, 68, 0.4)';
disIconBtn.style.color = 'var(--danger, #ef4444)';
disIconBtn.style.display = 'inline-flex';
disIconBtn.style.alignItems = 'center';
disIconBtn.style.justifyContent = 'center';
disIconBtn.style.cursor = 'pointer';
disIconBtn.style.flexShrink = '0';
disIconBtn.title = `Unmount local client folder (${mountName}) and return to server storage`;
disIconBtn.innerHTML = '<i data-lucide="eject" style="width:11px; height:11px;"></i>';
disIconBtn.onclick = (e) => {
e.stopPropagation();
disconnectPaneRemote(paneIndex);
};
container.appendChild(disIconBtn);
const rootCrumb = document.createElement('span');
rootCrumb.className = 'crumb';
rootCrumb.style.color = 'var(--accent)';
rootCrumb.style.fontWeight = '700';
rootCrumb.textContent = `💻 client://${mountName}`;
const rootTarget = `client://${mountName}`;
rootCrumb.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, rootTarget); };
container.appendChild(rootCrumb);
if (subpath) {
const parts = subpath.split('/').filter(Boolean);
let curSub = '';
parts.forEach((part, partIdx) => {
const sep = document.createElement('span');
sep.className = 'crumb-sep crumb-sep-dropdown';
sep.textContent = '/';
const curParentDir = `client://${mountName}` + (curSub ? `/${curSub}` : '');
sep.onclick = (e) => {
e.stopPropagation();
showBreadcrumbSubfolderDropdown(e, paneIndex, curParentDir);
};
container.appendChild(sep);
curSub += (curSub ? '/' : '') + part;
const target = `client://${mountName}/${curSub}`;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
}
if (window.lucide) lucide.createIcons();
return;
}
const protoMatch = pathStr.match(/^([a-zA-Z0-9_-]+):\/\/(.*)$/);
if (protoMatch) {
const proto = protoMatch[1].toLowerCase();
const rest = protoMatch[2];
const slashIdx = rest.indexOf('/');
let serverBase = rest;
let subpath = '';
if (slashIdx !== -1) {
if (proto === 'smb') {
const afterHostSlash = rest.indexOf('/', slashIdx + 1);
if (afterHostSlash !== -1) {
serverBase = rest.substring(0, afterHostSlash);
subpath = rest.substring(afterHostSlash + 1);
} else {
serverBase = rest;
subpath = '';
}
} else {
serverBase = rest.substring(0, slashIdx);
subpath = rest.substring(slashIdx + 1);
}
}
let displayBase = serverBase;
if (displayBase.includes('@')) {
const atIdx = displayBase.indexOf('@');
const auth = displayBase.substring(0, atIdx);
const hostPart = displayBase.substring(atIdx + 1);
if (auth.includes(':')) {
const userPart = auth.split(':')[0];
displayBase = `${userPart}@${hostPart}`;
}
}
// Always visible at the very front of the breadcrumb line: 🔌 Unplug / Disconnect icon
const disIconBtn = document.createElement('button');
disIconBtn.className = 'btn btn-xs pane-disconnect-chip';
disIconBtn.style.marginRight = '4px';
disIconBtn.style.padding = '2px 6px';
disIconBtn.style.borderRadius = '4px';
disIconBtn.style.background = 'rgba(239, 68, 68, 0.15)';
disIconBtn.style.border = '1px solid rgba(239, 68, 68, 0.4)';
disIconBtn.style.color = 'var(--danger, #ef4444)';
disIconBtn.style.display = 'inline-flex';
disIconBtn.style.alignItems = 'center';
disIconBtn.style.justifyContent = 'center';
disIconBtn.style.cursor = 'pointer';
disIconBtn.style.flexShrink = '0';
disIconBtn.title = `Disconnect from ${proto.toUpperCase()} server (${displayBase}) and return to local storage`;
disIconBtn.innerHTML = '<i data-lucide="unplug" style="width:12px; height:12px;"></i>';
disIconBtn.onclick = (e) => {
e.stopPropagation();
disconnectPaneRemote(paneIndex);
};
container.appendChild(disIconBtn);
const protoIcon = proto === 'smb' ? '🪟 ' : (proto === 'sftp' ? '🔒 ' : (proto === 'nfs' ? '📁 ' : (proto === 'webdav' ? '🌐 ' : '☁️ ')));
const rootCrumb = document.createElement('span');
rootCrumb.className = 'crumb';
rootCrumb.textContent = `${protoIcon}${proto}://${displayBase}`;
const rootTarget = `${proto}://${serverBase}`;
rootCrumb.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, rootTarget); };
container.appendChild(rootCrumb);
if (subpath) {
const parts = subpath.split('/').filter(Boolean);
let curSub = '';
parts.forEach(part => {
const sep = document.createElement('span');
sep.className = 'crumb-sep';
sep.textContent = '/';
container.appendChild(sep);
curSub += (curSub ? '/' : '') + part;
const target = `${proto}://${serverBase}/${curSub}`;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
}
if (window.lucide) lucide.createIcons();
return;
}
// Helper to create root breadcrumb (clean direct jump)
const makeRootCrumb = (displayText, rootTarget, icon = '') => {
const btn = document.createElement('span');
btn.className = 'crumb crumb-root';
btn.title = `Jump to ${displayText}`;
btn.innerHTML = `${icon ? icon + ' ' : ''}<span>${displayText}</span>`;
btn.onclick = (e) => {
e.stopPropagation();
loadPaneDirectory(paneIndex, rootTarget);
};
return btn;
};
// Helper to create clickable separator with subfolder dropdown
const makeSepDropdown = (sepChar, parentDir) => {
const sep = document.createElement('span');
sep.className = 'crumb-sep crumb-sep-dropdown';
sep.title = 'Browse subfolders';
sep.textContent = sepChar;
sep.onclick = (e) => {
e.stopPropagation();
showBreadcrumbSubfolderDropdown(e, paneIndex, parentDir);
};
return sep;
};
// Strip \\?\ prefix or leading slash before Windows drive letter if present
let cleanCrumbPath = pathStr || '/';
if (cleanCrumbPath.startsWith('\\\\?\\UNC\\')) {
cleanCrumbPath = '\\\\' + cleanCrumbPath.slice(8);
} else if (cleanCrumbPath.startsWith('\\\\?\\')) {
cleanCrumbPath = cleanCrumbPath.slice(4);
} else if (cleanCrumbPath.match(/^\/[a-zA-Z]:/)) {
cleanCrumbPath = cleanCrumbPath.slice(1);
}
// Check if path is a Windows drive path (e.g. C:\ or C:/ or C:\Users\Bolt)
const winDriveMatch = cleanCrumbPath.match(/^([a-zA-Z]:)[\\/]*(.*)$/);
const uncMatch = cleanCrumbPath.match(/^(\\\\[^\\\/]+[\\\/][^\\\/]+)(.*)$/) || cleanCrumbPath.match(/^(\/\/[^\/]+\/[^\/]+)(.*)$/);
if (winDriveMatch) {
const driveLetter = winDriveMatch[1].toUpperCase();
const driveRoot = `${driveLetter}\\`;
const rest = winDriveMatch[2];
const parts = rest.split(/[\\/]/).filter(Boolean);
const rootCrumb = makeRootCrumb(driveRoot, driveRoot, '🪟');
container.appendChild(rootCrumb);
let currentBuild = driveRoot;
parts.forEach((part, idx) => {
const sepParent = currentBuild;
const sep = makeSepDropdown('\\', sepParent);
container.appendChild(sep);
if (idx > 0 && !currentBuild.endsWith('\\')) currentBuild += '\\';
currentBuild += part;
const target = currentBuild;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
if (window.lucide) lucide.createIcons();
return;
}
if (uncMatch) {
const shareRoot = uncMatch[1].replace(/\//g, '\\');
const rest = uncMatch[2];
const parts = rest.split(/[\\/]/).filter(Boolean);
const rootCrumb = makeRootCrumb(shareRoot, shareRoot, '🖥️');
container.appendChild(rootCrumb);
let currentBuild = shareRoot;
parts.forEach(part => {
const sepParent = currentBuild;
const sep = makeSepDropdown('\\', sepParent);
container.appendChild(sep);
currentBuild += '\\' + part;
const target = currentBuild;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
if (window.lucide) lucide.createIcons();
return;
}
const parts = pathStr.split('/').filter(Boolean);
const rootCrumb = makeRootCrumb('/', '/', '📁');
container.appendChild(rootCrumb);
let currentBuild = '';
parts.forEach((part) => {
const sepParent = currentBuild || '/';
const sep = makeSepDropdown('/', sepParent);
container.appendChild(sep);
currentBuild += '/' + part;
const target = currentBuild;
const c = document.createElement('span');
c.className = 'crumb';
c.textContent = part;
c.onclick = (e) => { e.stopPropagation(); loadPaneDirectory(paneIndex, target); };
container.appendChild(c);
});
if (window.lucide) lucide.createIcons();
setTimeout(() => {
if (container) container.scrollLeft = container.scrollWidth;
}, 0);
}
// Global popover cleanup helper
function closeBreadcrumbPopovers() {
document.querySelectorAll('.breadcrumb-popover').forEach(p => p.remove());
}
const handleBreadcrumbOutside = (e) => {
if (!e.target.closest('.breadcrumb-popover') && !e.target.closest('.crumb-sep-dropdown') && !e.target.closest('.pane-node-btn') && !e.target.closest('.pane-node-chip')) {
closeBreadcrumbPopovers();
}
};
document.addEventListener('click', handleBreadcrumbOutside);
document.addEventListener('touchstart', handleBreadcrumbOutside);
async function showBreadcrumbSubfolderDropdown(event, paneIndex, parentDir) {
if (event && event.stopPropagation) event.stopPropagation();
const triggerKey = `crumb-sub-${paneIndex}-${parentDir}`;
const existing = document.querySelector('.breadcrumb-popover');
const wasOpen = existing && existing.dataset.triggerKey === triggerKey;
closeBreadcrumbPopovers();
if (wasOpen) return;
const trigger = event.currentTarget;
const rect = trigger.getBoundingClientRect();
const popover = document.createElement('div');
popover.className = 'breadcrumb-popover';
popover.dataset.triggerKey = triggerKey;
popover.style.top = `${rect.bottom + 4}px`;
popover.style.left = `${Math.max(8, rect.left - 20)}px`;
popover.innerHTML = '<div style="padding: 6px 8px; color: var(--text-muted); font-size: 11px;">Loading subfolders...</div>';
document.body.appendChild(popover);
try {
if (parentDir && parentDir.startsWith('client://')) {
const dirHandle = await resolveClientDirectoryHandle(parentDir);
const subdirs = [];
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === 'directory' && !name.startsWith('.')) {
const itemPath = parentDir.endsWith('/') ? `${parentDir}${name}` : `${parentDir}/${name}`;
subdirs.push({ name, path: itemPath });
}
}
subdirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
popover.innerHTML = '';
if (subdirs.length === 0) {
popover.innerHTML = '<div style="padding: 6px 8px; color: var(--text-muted); font-size: 11px;">(No subfolders)</div>';
return;
}
const header = document.createElement('div');
header.className = 'breadcrumb-popover-header';
header.textContent = `Subfolders (${subdirs.length})`;
popover.appendChild(header);
subdirs.forEach(dir => {
const item = document.createElement('div');
item.className = 'breadcrumb-popover-item';
const isCurrentChild = App.panes[paneIndex].path.startsWith(dir.path);
if (isCurrentChild) item.classList.add('active');
item.innerHTML = `<span style="font-size: 12px;">📁</span> <span>${escapeHtml(dir.name)}</span>`;
item.onclick = () => {
loadPaneDirectory(paneIndex, dir.path);
closeBreadcrumbPopovers();
};
popover.appendChild(item);
});
return;
}
const endpoint = getPaneEndpoint(paneIndex);
const headers = getPaneAuthHeaders(paneIndex);
const authUrl = resolveAuthUri(parentDir);
const res = await fetch(`${endpoint}/api/fs/list?path=${encodeURIComponent(authUrl)}&show_hidden=false`, {
headers
});
if (!res.ok) {
popover.innerHTML = '<div style="padding: 6px 8px; color: var(--text-muted); font-size: 11px;">(Cannot access folder)</div>';
return;
}
const data = await res.json();
const subdirs = (data.entries || []).filter(e => e.is_dir);
popover.innerHTML = '';
if (subdirs.length === 0) {
popover.innerHTML = '<div style="padding: 6px 8px; color: var(--text-muted); font-size: 11px;">(No subfolders)</div>';
return;
}
const header = document.createElement('div');
header.className = 'breadcrumb-popover-header';
header.textContent = `Subfolders (${subdirs.length})`;
popover.appendChild(header);
subdirs.sort((a, b) => a.name.localeCompare(b.name)).forEach(dir => {
const item = document.createElement('div');
item.className = 'breadcrumb-popover-item';
const isCurrentChild = App.panes[paneIndex].path.startsWith(dir.path);
if (isCurrentChild) item.classList.add('active');
item.innerHTML = `<span style="font-size: 12px;">📁</span> <span>${escapeHtml(dir.name)}</span>`;
item.onclick = () => {
loadPaneDirectory(paneIndex, dir.path);
closeBreadcrumbPopovers();
};
popover.appendChild(item);
});
} catch (err) {
popover.innerHTML = '<div style="padding: 6px 8px; color: var(--danger); font-size: 11px;">Failed to load subfolders</div>';
}
}
function renderPaneTable(paneIndex, preserveScroll = true) {
const pane = App.panes[paneIndex];
const tableEl = document.getElementById(`pane-table-${paneIndex}`);
const tbody = document.getElementById(`pane-tbody-${paneIndex}`);
const gridEl = document.getElementById(`pane-grid-${paneIndex}`);
const compactEl = document.getElementById(`pane-compact-${paneIndex}`);
const mainEl = document.getElementById(`pane-main-${paneIndex}`);
if (!tbody) return;
const prevScrollTop = (preserveScroll && mainEl) ? mainEl.scrollTop : 0;
const prevScrollLeft = (preserveScroll && mainEl) ? mainEl.scrollLeft : 0;
// Manage Flat Branch View Banner
let branchBanner = document.getElementById(`pane-branch-banner-${paneIndex}`);
if (pane && pane.isBranchView) {
if (!branchBanner && mainEl) {
branchBanner = document.createElement('div');
branchBanner.className = 'pane-branch-banner';
branchBanner.id = `pane-branch-banner-${paneIndex}`;
mainEl.insertBefore(branchBanner, mainEl.firstChild);
}
if (branchBanner) {
branchBanner.style.display = 'flex';
const count = pane.entries ? pane.entries.length : 0;
const truncatedBadge = pane.isBranchTruncated
? `<span class="branch-banner-badge" title="Directory listing reached safety limit of ${pane.branchMaxLimit || 5000} items for speed">Capped at ${pane.branchMaxLimit || 5000} items</span>`
: '';
branchBanner.innerHTML = `
<div class="branch-banner-info">
<img src="assets/amber-git-branch.webp" class="branch-banner-icon" alt="Branch" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle;">
<span class="branch-banner-title">Flat Branch View</span>
<span class="branch-banner-count">• ${count} items flattened</span>
${truncatedBadge}
</div>
<button class="branch-banner-exit" onclick="toggleBranchView(${paneIndex})" title="Exit Flat Branch View and return to standard folder view (Ctrl+B)">
<i data-lucide="x" style="width: 12px; height: 12px;"></i>
<span>Exit Branch View <kbd>Ctrl+B</kbd></span>
</button>
`;
}
} else if (branchBanner) {
branchBanner.remove();
}
// Manage Trash Bin Banner
let trashBanner = document.getElementById(`pane-trash-banner-${paneIndex}`);
const isTrash = isTrashDirectory(pane?.path);
if (isTrash) {
if (!trashBanner && mainEl) {
trashBanner = document.createElement('div');
trashBanner.className = 'pane-trash-banner';
trashBanner.id = `pane-trash-banner-${paneIndex}`;
mainEl.insertBefore(trashBanner, mainEl.firstChild);
}
if (trashBanner) {
trashBanner.style.display = 'flex';
const isWinNative = isWindowsHost() && App.windowsNativeOps;
const count = pane.entries ? pane.entries.filter(e => e.name !== '..').length : 0;
const selectedCount = pane.selected ? pane.selected.size : 0;
trashBanner.onmousedown = (e) => e.stopPropagation();
trashBanner.innerHTML = `
<div class="pane-trash-info" style="display: flex; align-items: center; gap: 8px;">
<i data-lucide="trash-2" style="width: 14px; height: 14px; color: var(--accent);"></i>
<span style="font-weight: 700; font-size: 11.5px; color: var(--accent);">${escapeHtml(getTrashDisplayName(true))}</span>
${isWinNative
? '<span class="badge" style="font-size: 9px; padding: 1px 5px; background: rgba(16, 185, 129, 0.2); color: #10b981;">Windows Native ($Recycle.Bin)</span>'
: '<span class="badge" style="font-size: 9px; padding: 1px 5px; background: rgba(59, 130, 246, 0.2); color: #60a5fa;">Brum Built-in (.local/share/Trash)</span>'}
<span style="font-size: 11px; color: var(--text-dim);">• ${count} item${count === 1 ? '' : 's'}</span>
${selectedCount > 0 ? `<span class="badge" style="font-size: 9px; padding: 1px 5px; background: rgba(245, 158, 11, 0.2); color: var(--accent);">${selectedCount} selected</span>` : ''}
</div>
<div class="pane-trash-actions" style="display: flex; align-items: center; gap: 6px;" onmousedown="event.stopPropagation()">
${isWinNative ? `
<button class="btn btn-xs btn-outline" onmousedown="event.stopPropagation()" onclick="openNativeRecycleBin()" title="Open Windows native Recycle Bin in Explorer" style="font-size: 10.5px; padding: 2px 8px; color: var(--text-dim); border-color: var(--border);">
<i data-lucide="external-link" style="width: 11px; height: 11px;"></i> Open in Windows
</button>
` : ''}
<button class="btn btn-xs btn-outline" onmousedown="event.stopPropagation()" onclick="restoreSelectedTrashItems(${paneIndex})" title="Restore selected files (or highlighted file, or all files) back to original location" style="font-size: 10.5px; padding: 2px 8px; color: var(--accent); border-color: var(--accent);" ${count === 0 ? 'disabled' : ''}>
<i data-lucide="rotate-ccw" style="width: 11px; height: 11px;"></i> ${selectedCount > 0 ? `Restore (${selectedCount})` : 'Restore'}
</button>
<button class="btn btn-xs btn-outline" onmousedown="event.stopPropagation()" onclick="deleteSelectedTrashItems(${paneIndex})" title="Permanently delete selected files" style="font-size: 10.5px; padding: 2px 8px; color: var(--danger); border-color: rgba(239,68,68,0.4);" ${count === 0 ? 'disabled' : ''}>
<i data-lucide="x-circle" style="width: 11px; height: 11px;"></i> ${selectedCount > 0 ? `Delete (${selectedCount})` : 'Delete Permanently'}
</button>
<button class="btn btn-xs btn-danger-action" onmousedown="event.stopPropagation()" onclick="promptEmptyTrash(${paneIndex})" title="Permanently empty all items in ${escapeHtml(getTrashDisplayName(false).toLowerCase())}" style="font-size: 10.5px; padding: 2px 8px;" ${(isWinNative || count > 0) ? '' : 'disabled'}>
<i data-lucide="trash" style="width: 11px; height: 11px;"></i> ${isWinNative ? 'Empty Recycle Bin' : 'Empty Trash'}
</button>
</div>
`;
if (typeof lucide !== 'undefined' && lucide.createIcons) lucide.createIcons({ root: trashBanner });
}
} else if (trashBanner) {
trashBanner.remove();
}
const mode = pane.viewMode || 'details';
const isMultiLine = localStorage.getItem('cd_file_list_multiline') === '1';
const showColSeparators = localStorage.getItem('cd_col_separators') !== '0';
// Toggle visible container
if (tableEl) {
tableEl.style.display = (mode === 'details') ? '' : 'none';
tableEl.classList.toggle('file-table-multiline', isMultiLine);
tableEl.classList.toggle('file-table-borderless', !showColSeparators);
}
if (gridEl) gridEl.style.display = (mode === 'grid') ? 'grid' : 'none';
if (compactEl) compactEl.style.display = (mode === 'compact') ? 'flex' : 'none';
let filtered = pane.entries;
if (pane.filterText) {
const ft = pane.filterText.toLowerCase();
filtered = filtered.filter(e => e.name.toLowerCase().includes(ft));
}
// Sort
filtered.sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
let cmp = 0;
if (pane.sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (pane.sortBy === 'ext') {
const extA = a.name.includes('.') ? a.name.split('.').pop() : '';
const extB = b.name.includes('.') ? b.name.split('.').pop() : '';
cmp = extA.localeCompare(extB);
}
else if (pane.sortBy === 'size') cmp = a.size - b.size;
else if (pane.sortBy === 'modified') cmp = (a.modified || 0) - (b.modified || 0);
return pane.sortAsc ? cmp : -cmp;
});
const isTouchDevice = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
const isImageFile = (name) => /\.(png|jpe?g|webp|svg|gif|bmp|ico|avif|tiff)$/i.test(name);
// Parent directory ".." when not at root
const showParent = App.showParentDir && pane.path !== '/' && pane.path !== '' && !pane.filterText;
if (mode === 'grid') {
const gridFrag = document.createDocumentFragment();
if (showParent) {
const pCard = document.createElement('div');
pCard.className = 'grid-gallery-card parent-dir-card';
pCard.innerHTML = `
<div class="grid-thumb-wrapper">
<i data-lucide="corner-left-up" style="width: 32px; height: 32px; color: var(--accent);"></i>
</div>
<div class="grid-card-name" style="font-weight: 700; color: var(--accent);">..</div>
<div class="grid-card-meta"><UP></div>
`;
pCard.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
e.stopPropagation();
setActivePane(paneIndex);
if (pane.selected.size > 0 || (pane.cursorIndex !== undefined && pane.cursorIndex !== -1)) {
pane.selected.clear();
pane.cursorIndex = -1;
gridEl.querySelectorAll('.grid-gallery-card.selected').forEach(r => r.classList.remove('selected'));
gridEl.querySelectorAll('.grid-gallery-card.cursor-focus').forEach(r => r.classList.remove('cursor-focus'));
updatePaneFooter(paneIndex);
if (typeof updateMobileBottomBar === 'function') updateMobileBottomBar();
}
};
pCard.ondblclick = (e) => {
e.stopPropagation();
setActivePane(paneIndex);
navPaneUp(paneIndex);
};
gridFrag.appendChild(pCard);
}
filtered.forEach((entry, idx) => {
const isSelected = pane.selected.has(entry.path);
const card = document.createElement('div');
card.className = `grid-gallery-card ${isSelected ? 'selected' : ''} ${idx === pane.cursorIndex ? 'cursor-focus' : ''}`;
card.draggable = !isTouchDevice;
card.dataset.path = entry.path;
card.dataset.index = idx;
let thumbHtml = '';
if (!entry.is_dir && isImageFile(entry.name)) {
thumbHtml = `<img src="${getDownloadUrl(entry.path, true)}" class="grid-thumb-img" loading="lazy" alt="${escapeHtml(entry.name)}" onerror="this.src='assets/brum_icon.webp'">`;
} else {
thumbHtml = renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'lg');
}
card.innerHTML = `
<div class="grid-thumb-wrapper">${thumbHtml}</div>
<div class="grid-card-name" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</div>
<div class="grid-card-meta">${entry.is_dir ? '<DIR>' : formatBytes(entry.size)}</div>
`;
if (!isTouchDevice) {
card.ondragstart = (e) => {
const selectedPaths = pane.selected.size > 0 ? Array.from(pane.selected) : [entry.path];
const payload = { sourcePane: paneIndex, paths: selectedPaths };
window._activeDraggedPayload = payload;
const jsonPayload = JSON.stringify(payload);
e.dataTransfer.setData('text/plain', jsonPayload);
e.dataTransfer.setData('application/json', jsonPayload);
e.dataTransfer.effectAllowed = 'all';
};
card.ondragend = () => {
window._activeDraggedPayload = null;
};
card.ondragenter = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
};
card.ondragover = (e) => {
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) return;
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
if (entry.is_dir) {
e.stopPropagation();
card.classList.add('drag-over-card');
}
};
card.ondragleave = () => {
if (entry.is_dir) {
card.classList.remove('drag-over-card');
}
};
card.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
card.classList.remove('drag-over-card');
if (entry.is_dir) {
handlePaneDrop(e, paneIndex, entry.path);
} else {
handlePaneDrop(e, paneIndex, pane.path);
}
};
}
let cTouchStart = 0;
let cTouchStartX = 0;
let cTouchStartY = 0;
let cIsScrolling = false;
let cIsLongPress = false;
let cTouchTimer = null;
card.ontouchstart = (e) => {
if (!e.touches || e.touches.length === 0) return;
cTouchStart = Date.now();
cTouchStartX = e.touches[0].clientX;
cTouchStartY = e.touches[0].clientY;
cIsScrolling = false;
cIsLongPress = false;
cTouchTimer = setTimeout(() => {
if (cIsScrolling) return;
cIsLongPress = true;
triggerHaptic(40);
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.add(entry.path);
card.classList.add('selected');
}
pane.cursorIndex = idx;
pane.anchorIndex = idx;
updateMobileBottomBar();
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(cTouchStartX, cTouchStartY);
}, 450);
};
card.ontouchmove = (e) => {
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - cTouchStartX);
const dy = Math.abs(e.touches[0].clientY - cTouchStartY);
if (dx > 6 || dy > 6) {
cIsScrolling = true;
if (cTouchTimer) {
clearTimeout(cTouchTimer);
cTouchTimer = null;
}
}
};
card.ontouchend = (e) => {
if (cTouchTimer) {
clearTimeout(cTouchTimer);
cTouchTimer = null;
}
if (cIsScrolling || cIsLongPress) return;
if (Date.now() - cTouchStart < 350) {
setActivePane(paneIndex);
if (pane.selected.size > 0) {
if (pane.selected.has(entry.path)) {
pane.selected.delete(entry.path);
card.classList.remove('selected');
} else {
pane.selected.add(entry.path);
card.classList.add('selected');
}
pane.anchorIndex = idx;
updateMobileBottomBar();
return;
}
openFileByType(entry, paneIndex);
}
};
card.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
setActivePane(paneIndex);
if (e.shiftKey) {
const anchor = (typeof pane.anchorIndex === 'number' && pane.anchorIndex >= 0 && pane.anchorIndex < filtered.length)
? pane.anchorIndex
: (pane.cursorIndex >= 0 ? pane.cursorIndex : 0);
const start = Math.min(anchor, idx);
const end = Math.max(anchor, idx);
if (!e.ctrlKey && !e.metaKey) {
pane.selected.clear();
}
for (let i = start; i <= end; i++) {
if (filtered[i]) pane.selected.add(filtered[i].path);
}
pane.cursorIndex = idx;
} else if (e.ctrlKey || e.metaKey) {
if (pane.selected.has(entry.path)) pane.selected.delete(entry.path);
else pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
} else {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
}
renderPaneTable(paneIndex);
};
card.ondblclick = () => {
openFileByType(entry, paneIndex);
};
card.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
renderPaneTable(paneIndex);
} else {
pane.cursorIndex = idx;
}
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(e.clientX, e.clientY);
};
gridFrag.appendChild(card);
});
if (gridEl) {
gridEl.innerHTML = '';
gridEl.appendChild(gridFrag);
}
} else if (mode === 'compact') {
const compactFrag = document.createDocumentFragment();
if (showParent) {
const pItem = document.createElement('div');
pItem.className = 'compact-list-item parent-dir-item';
pItem.innerHTML = `
<i data-lucide="corner-left-up" style="width: 15px; height: 15px; color: var(--accent); flex-shrink: 0;"></i>
<span style="font-weight: 700; color: var(--accent);">..</span>
`;
let pTouchStart = 0;
let pTouchStartX = 0;
let pTouchStartY = 0;
let pIsScrolling = false;
pItem.ontouchstart = (e) => {
if (!e.touches || e.touches.length === 0) return;
pTouchStart = Date.now();
pTouchStartX = e.touches[0].clientX;
pTouchStartY = e.touches[0].clientY;
pIsScrolling = false;
};
pItem.ontouchmove = (e) => {
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - pTouchStartX);
const dy = Math.abs(e.touches[0].clientY - pTouchStartY);
if (dx > 6 || dy > 6) pIsScrolling = true;
};
pItem.ontouchend = (e) => {
if (pIsScrolling) return;
if (Date.now() - pTouchStart < 350) {
e.preventDefault();
setActivePane(paneIndex);
navPaneUp(paneIndex);
}
};
pItem.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
e.stopPropagation();
setActivePane(paneIndex);
if (pane.selected.size > 0 || (pane.cursorIndex !== undefined && pane.cursorIndex !== -1)) {
pane.selected.clear();
pane.cursorIndex = -1;
compactEl.querySelectorAll('.compact-list-item.selected').forEach(r => r.classList.remove('selected'));
compactEl.querySelectorAll('.compact-list-item.cursor-focus').forEach(r => r.classList.remove('cursor-focus'));
updatePaneFooter(paneIndex);
if (typeof updateMobileBottomBar === 'function') updateMobileBottomBar();
}
};
pItem.ondblclick = (e) => {
e.stopPropagation();
setActivePane(paneIndex);
navPaneUp(paneIndex);
};
compactFrag.appendChild(pItem);
}
filtered.forEach((entry, idx) => {
const isSelected = pane.selected.has(entry.path);
const item = document.createElement('div');
item.className = `compact-list-item ${isSelected ? 'selected' : ''} ${idx === pane.cursorIndex ? 'cursor-focus' : ''}`;
item.dataset.path = entry.path;
item.dataset.index = idx;
item.draggable = !isTouchDevice;
const iconHtml = renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'sm');
item.innerHTML = `
${iconHtml}
<span class="compact-item-name" title="${escapeHtml(entry.name)}" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(entry.name)}</span>
`;
if (!isTouchDevice) {
item.ondragstart = (e) => {
const selectedPaths = pane.selected.size > 0 ? Array.from(pane.selected) : [entry.path];
const payload = { sourcePane: paneIndex, paths: selectedPaths };
window._activeDraggedPayload = payload;
const jsonPayload = JSON.stringify(payload);
e.dataTransfer.setData('text/plain', jsonPayload);
e.dataTransfer.setData('application/json', jsonPayload);
e.dataTransfer.effectAllowed = 'all';
};
item.ondragend = () => {
window._activeDraggedPayload = null;
};
item.ondragenter = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
};
item.ondragover = (e) => {
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) return;
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
if (entry.is_dir) {
e.stopPropagation();
item.classList.add('drag-over-item');
}
};
item.ondragleave = () => {
if (entry.is_dir) {
item.classList.remove('drag-over-item');
}
};
item.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
item.classList.remove('drag-over-item');
if (entry.is_dir) {
handlePaneDrop(e, paneIndex, entry.path);
} else {
handlePaneDrop(e, paneIndex, pane.path);
}
};
}
let iTouchStart = 0;
let iTouchStartX = 0;
let iTouchStartY = 0;
let iIsScrolling = false;
let iIsLongPress = false;
let iTouchTimer = null;
item.ontouchstart = (e) => {
if (!e.touches || e.touches.length === 0) return;
iTouchStart = Date.now();
iTouchStartX = e.touches[0].clientX;
iTouchStartY = e.touches[0].clientY;
iIsScrolling = false;
iIsLongPress = false;
iTouchTimer = setTimeout(() => {
if (iIsScrolling) return;
iIsLongPress = true;
triggerHaptic(40);
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.add(entry.path);
item.classList.add('selected');
}
pane.cursorIndex = idx;
pane.anchorIndex = idx;
updateMobileBottomBar();
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(iTouchStartX, iTouchStartY);
}, 450);
};
item.ontouchmove = (e) => {
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - iTouchStartX);
const dy = Math.abs(e.touches[0].clientY - iTouchStartY);
if (dx > 6 || dy > 6) {
iIsScrolling = true;
if (iTouchTimer) {
clearTimeout(iTouchTimer);
iTouchTimer = null;
}
}
};
item.ontouchend = (e) => {
if (iTouchTimer) {
clearTimeout(iTouchTimer);
iTouchTimer = null;
}
if (iIsScrolling || iIsLongPress) return;
if (Date.now() - iTouchStart < 350) {
setActivePane(paneIndex);
if (pane.selected.size > 0) {
if (pane.selected.has(entry.path)) {
pane.selected.delete(entry.path);
item.classList.remove('selected');
} else {
pane.selected.add(entry.path);
item.classList.add('selected');
}
pane.anchorIndex = idx;
updateMobileBottomBar();
return;
}
openFileByType(entry, paneIndex);
}
};
item.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
setActivePane(paneIndex);
if (e.shiftKey) {
const anchor = (typeof pane.anchorIndex === 'number' && pane.anchorIndex >= 0 && pane.anchorIndex < filtered.length)
? pane.anchorIndex
: (pane.cursorIndex >= 0 ? pane.cursorIndex : 0);
const start = Math.min(anchor, idx);
const end = Math.max(anchor, idx);
if (!e.ctrlKey && !e.metaKey) {
pane.selected.clear();
}
for (let i = start; i <= end; i++) {
if (filtered[i]) pane.selected.add(filtered[i].path);
}
pane.cursorIndex = idx;
} else if (e.ctrlKey || e.metaKey) {
if (pane.selected.has(entry.path)) pane.selected.delete(entry.path);
else pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
} else {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
}
renderPaneTable(paneIndex);
};
item.ondblclick = () => openFileByType(entry, paneIndex);
item.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
renderPaneTable(paneIndex);
} else {
pane.cursorIndex = idx;
}
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(e.clientX, e.clientY);
};
compactFrag.appendChild(item);
});
if (compactEl) {
compactEl.innerHTML = '';
compactEl.appendChild(compactFrag);
}
} else {
// Details Mode (Orthodox Table)
const tbodyFrag = document.createDocumentFragment();
const showDirTag = localStorage.getItem('cd_show_dir_tag') === '1';
const compactDates = localStorage.getItem('cd_compact_dates') !== '0';
const isMultiLine = localStorage.getItem('cd_file_list_multiline') === '1';
if (showParent) {
const parentTr = document.createElement('tr');
parentTr.className = 'file-row parent-dir-row';
parentTr.draggable = !isTouchDevice;
let parentTouchStart = 0;
let parentTouchStartX = 0;
let parentTouchStartY = 0;
let parentIsScrolling = false;
parentTr.ontouchstart = (e) => {
if (!e.touches || e.touches.length === 0) return;
parentTouchStart = Date.now();
parentTouchStartX = e.touches[0].clientX;
parentTouchStartY = e.touches[0].clientY;
parentIsScrolling = false;
};
parentTr.ontouchmove = (e) => {
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - parentTouchStartX);
const dy = Math.abs(e.touches[0].clientY - parentTouchStartY);
if (dx > 6 || dy > 6) {
parentIsScrolling = true;
}
};
parentTr.ontouchend = (e) => {
if (parentIsScrolling) return;
if (Date.now() - parentTouchStart < 350) {
e.preventDefault();
setActivePane(paneIndex);
navPaneUp(paneIndex);
}
};
parentTr.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
e.stopPropagation();
setActivePane(paneIndex);
if (pane.selected.size > 0 || (pane.cursorIndex !== undefined && pane.cursorIndex !== -1)) {
pane.selected.clear();
pane.cursorIndex = -1;
tbody.querySelectorAll('.file-row.selected').forEach(r => r.classList.remove('selected'));
tbody.querySelectorAll('.file-row.cursor-focus').forEach(r => r.classList.remove('cursor-focus'));
updatePaneFooter(paneIndex);
if (typeof updateMobileBottomBar === 'function') updateMobileBottomBar();
}
};
parentTr.ondblclick = (e) => {
e.stopPropagation();
setActivePane(paneIndex);
navPaneUp(paneIndex);
};
if (!isTouchDevice) {
parentTr.ondragenter = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
};
parentTr.ondragover = (e) => {
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
parentTr.classList.add('drag-over-row');
};
parentTr.ondragleave = () => parentTr.classList.remove('drag-over-row');
parentTr.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
parentTr.classList.remove('drag-over-row');
const parent = pane.parentPath || getParentDirectory(pane.path) || '/';
handlePaneDrop(e, paneIndex, parent);
};
}
parentTr.innerHTML = `
<td class="file-cell file-cell-icon">
<div class="row-icon-wrapper">
<i data-lucide="corner-left-up" style="width: 15px; height: 15px; color: var(--accent);"></i>
</div>
</td>
<td class="file-cell file-cell-name">
${isMultiLine ? `
<div class="file-name-wrapper">
<div class="file-name-title-row">
<span class="file-name-text" style="font-weight: 700; color: var(--accent); font-size: 13px;">..</span>
</div>
<div class="file-subtext-mobile">
<span class="subtext-size" style="color: var(--accent);">${showDirTag ? '<UP>' : '..'}</span>
</div>
</div>
` : `
<span class="file-name-text" style="font-weight: 700; color: var(--accent); font-size: 13px;">..</span>
`}
</td>
${ColumnConfig.visibility.ext ? '<td class="file-cell file-cell-mono file-cell-ext">-</td>' : ''}
${ColumnConfig.visibility.size ? `<td class="file-cell file-cell-mono file-cell-size" style="color: var(--accent); font-weight: 600;">${showDirTag ? '<UP>' : '-'}</td>` : ''}
${ColumnConfig.visibility.modified ? '<td class="file-cell file-cell-mono file-cell-modified">-</td>' : ''}
${ColumnConfig.visibility.created ? '<td class="file-cell file-cell-mono file-cell-created">-</td>' : ''}
${ColumnConfig.visibility.mode ? '<td class="file-cell file-cell-mono file-cell-mode">-</td>' : ''}
${ColumnConfig.visibility.owner ? '<td class="file-cell file-cell-mono file-cell-owner">-</td>' : ''}
${ColumnConfig.visibility.group ? '<td class="file-cell file-cell-mono file-cell-group">-</td>' : ''}
${ColumnConfig.visibility.hash ? '<td class="file-cell file-cell-mono file-cell-hash">-</td>' : ''}
${ColumnConfig.visibility.tags ? '<td class="file-cell file-cell-mono file-cell-tags">-</td>' : ''}
`;
tbodyFrag.appendChild(parentTr);
}
filtered.forEach((entry, idx) => {
const tagInfo = fileTagsMap.get(entry.path);
let colorClass = '';
let tagsHtml = '';
if (tagInfo) {
if (tagInfo.color_label && tagInfo.color_label !== 'none') {
colorClass = `file-row-color-${tagInfo.color_label}`;
}
if (tagInfo.tags && tagInfo.tags.length > 0) {
tagsHtml = tagInfo.tags.map(t => `<span class="file-tag-badge">#${escapeHtml(t)}</span>`).join('');
}
}
const isSelected = pane.selected.has(entry.path);
const tr = document.createElement('tr');
tr.className = `file-row ${isSelected ? 'selected' : ''} ${idx === pane.cursorIndex ? 'cursor-focus' : ''} ${colorClass}`;
tr.draggable = !isTouchDevice;
tr.dataset.path = entry.path;
tr.dataset.index = idx;
if (!isTouchDevice) {
tr.ondragstart = (e) => {
const selectedPaths = pane.selected.size > 0 ? Array.from(pane.selected) : [entry.path];
const payload = { sourcePane: paneIndex, paths: selectedPaths };
window._activeDraggedPayload = payload;
const jsonPayload = JSON.stringify(payload);
e.dataTransfer.setData('text/plain', jsonPayload);
e.dataTransfer.setData('application/json', jsonPayload);
e.dataTransfer.effectAllowed = 'all';
};
tr.ondragend = () => {
window._activeDraggedPayload = null;
};
tr.ondragenter = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
};
tr.ondragover = (e) => {
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) return;
e.preventDefault();
e.dataTransfer.dropEffect = (e.ctrlKey || e.altKey) ? 'copy' : 'move';
if (entry.is_dir) {
e.stopPropagation();
tr.classList.add('drag-over-row');
}
};
tr.ondragleave = () => {
if (entry.is_dir) {
tr.classList.remove('drag-over-row');
}
};
tr.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
tr.classList.remove('drag-over-row');
if (entry.is_dir) {
handlePaneDrop(e, paneIndex, entry.path);
} else {
handlePaneDrop(e, paneIndex, pane.path);
}
};
}
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
let isScrolling = false;
let isLongPress = false;
let touchTimer = null;
tr.ontouchstart = (e) => {
if (!e.touches || e.touches.length === 0) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
isScrolling = false;
isLongPress = false;
touchTimer = setTimeout(() => {
if (!isScrolling) {
isLongPress = true;
triggerHaptic(40);
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
tr.classList.add('selected');
const iconWrap = tr.querySelector('.row-icon-wrapper');
if (iconWrap && isMobile) {
iconWrap.classList.add('selected');
iconWrap.innerHTML = `<i data-lucide="check" class="file-icon check-icon" style="width: 15px; height: 15px;"></i>`;
if (window.lucide) lucide.createIcons();
}
updateMobileBottomBar();
}
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(touchStartX, touchStartY);
}
}, 500);
};
tr.ontouchmove = (e) => {
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - touchStartX);
const dy = Math.abs(e.touches[0].clientY - touchStartY);
if (dx > 8 || dy > 8) {
isScrolling = true;
if (touchTimer) {
clearTimeout(touchTimer);
touchTimer = null;
}
}
};
tr.ontouchend = (e) => {
if (touchTimer) {
clearTimeout(touchTimer);
touchTimer = null;
}
if (isScrolling || isLongPress) return;
const duration = Date.now() - touchStartTime;
if (duration < 400) {
e.preventDefault();
setActivePane(paneIndex);
if (pane.selected.size > 0) {
if (pane.selected.has(entry.path)) {
pane.selected.delete(entry.path);
tr.classList.remove('selected');
const iconWrap = tr.querySelector('.row-icon-wrapper');
if (iconWrap && isMobile) {
iconWrap.classList.remove('selected');
iconWrap.innerHTML = renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'sm');
if (window.lucide) lucide.createIcons();
}
} else {
pane.selected.add(entry.path);
tr.classList.add('selected');
const iconWrap = tr.querySelector('.row-icon-wrapper');
if (iconWrap && isMobile) {
iconWrap.classList.add('selected');
iconWrap.innerHTML = `<i data-lucide="check" class="file-icon check-icon" style="width: 15px; height: 15px;"></i>`;
if (window.lucide) lucide.createIcons();
}
}
pane.anchorIndex = idx;
updateMobileBottomBar();
return;
}
openFileByType(entry, paneIndex);
}
};
tr.onclick = (e) => {
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return;
setActivePane(paneIndex);
if (e.shiftKey) {
const anchor = (typeof pane.anchorIndex === 'number' && pane.anchorIndex >= 0 && pane.anchorIndex < filtered.length)
? pane.anchorIndex
: (pane.cursorIndex >= 0 ? pane.cursorIndex : 0);
const start = Math.min(anchor, idx);
const end = Math.max(anchor, idx);
if (!e.ctrlKey && !e.metaKey) {
pane.selected.clear();
}
for (let i = start; i <= end; i++) {
if (filtered[i]) pane.selected.add(filtered[i].path);
}
pane.cursorIndex = idx;
} else if (e.ctrlKey || e.metaKey) {
if (pane.selected.has(entry.path)) pane.selected.delete(entry.path);
else pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
} else {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
}
renderPaneTable(paneIndex);
updateMobileBottomBar();
};
tr.ondblclick = () => {
openFileByType(entry, paneIndex);
};
tr.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
const isTouch = window.innerWidth <= 768 || window.matchMedia('(pointer: coarse)').matches;
if (isTouch) return false;
setActivePane(paneIndex);
if (!pane.selected.has(entry.path)) {
pane.selected.clear();
pane.selected.add(entry.path);
pane.anchorIndex = idx;
pane.cursorIndex = idx;
renderPaneTable(paneIndex);
} else {
pane.cursorIndex = idx;
}
App.contextItem = entry;
App.contextPaneIndex = paneIndex;
showContextMenu(e.clientX, e.clientY);
};
let iconHtml = '';
const isMobile = window.innerWidth <= 768;
const showCheckBadge = isSelected && isMobile;
if (showCheckBadge) {
iconHtml = `<i data-lucide="check" class="file-icon check-icon" style="width: 15px; height: 15px;"></i>`;
} else {
iconHtml = renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'sm');
}
const ext = entry.name.includes('.') ? entry.name.split('.').pop() : '';
const sizeDisplay = entry.is_dir ? (showDirTag ? '<DIR>' : '') : formatBytes(entry.size);
tr.innerHTML = `
<td class="file-cell file-cell-icon">
<div class="row-icon-wrapper ${showCheckBadge ? 'selected' : ''}">
${iconHtml}
</div>
</td>
<td class="file-cell file-cell-name">
${isMultiLine ? `
<div class="file-name-wrapper">
<div class="file-name-title-row">
<span class="file-name-text">${escapeHtml(entry.name)}</span>${tagsHtml}
</div>
<div class="file-subtext-mobile">
<span class="subtext-size">${entry.is_dir ? (showDirTag ? '<DIR>' : 'DIR') : formatBytes(entry.size)}</span>
<span class="subtext-sep">•</span>
<span class="subtext-perms" title="Permissions">${escapeHtml(entry.permissions || entry.mode_octal || '-')}</span>
${entry.owner ? `<span class="subtext-sep">•</span><span class="subtext-owner">${escapeHtml(entry.owner)}${entry.group ? ':' + escapeHtml(entry.group) : ''}</span>` : ''}
${entry.modified ? `<span class="subtext-sep">•</span><span class="subtext-date">${formatDate(entry.modified, true)}</span>` : ''}
</div>
</div>
` : `
<span class="file-name-text">${escapeHtml(entry.name)}</span>${tagsHtml}
`}
</td>
${ColumnConfig.visibility.ext ? `<td class="file-cell file-cell-mono file-cell-ext">${entry.is_dir ? '' : escapeHtml(ext)}</td>` : ''}
${ColumnConfig.visibility.size ? `<td class="file-cell file-cell-mono file-cell-size">${sizeDisplay}</td>` : ''}
${ColumnConfig.visibility.modified ? `<td class="file-cell file-cell-mono file-cell-modified">${formatDate(entry.modified, compactDates)}</td>` : ''}
${ColumnConfig.visibility.created ? `<td class="file-cell file-cell-mono file-cell-created">${entry.created ? formatDate(entry.created, compactDates) : '-'}</td>` : ''}
${ColumnConfig.visibility.mode ? `<td class="file-cell file-cell-mono file-cell-mode" title="${entry.permissions}">${entry.mode_octal || entry.permissions}</td>` : ''}
${ColumnConfig.visibility.owner ? `<td class="file-cell file-cell-mono file-cell-owner">${entry.owner || '-'}</td>` : ''}
${ColumnConfig.visibility.group ? `<td class="file-cell file-cell-mono file-cell-group">${entry.group || '-'}</td>` : ''}
${ColumnConfig.visibility.hash ? `<td class="file-cell file-cell-mono file-cell-hash" style="font-size: 10px; color: var(--text-dim);">${entry.sha256 ? entry.sha256.slice(0, 8) + '...' : '-'}</td>` : ''}
${ColumnConfig.visibility.tags ? `<td class="file-cell file-cell-mono file-cell-tags">${tagInfo?.color_label ? `<span class="color-dot-mini color-${tagInfo.color_label}" style="vertical-align: middle;"></span> ` : ''}${tagInfo?.tags?.length ? tagInfo.tags.join(', ') : '-'}</td>` : ''}
`;
tbodyFrag.appendChild(tr);
});
tbody.innerHTML = '';
tbody.appendChild(tbodyFrag);
}
if (preserveScroll && mainEl && prevScrollTop > 0) {
mainEl.scrollTop = prevScrollTop;
mainEl.scrollLeft = prevScrollLeft;
requestAnimationFrame(() => {
if (mainEl && prevScrollTop > 0 && Math.abs(mainEl.scrollTop - prevScrollTop) > 5) {
mainEl.scrollTop = prevScrollTop;
mainEl.scrollLeft = prevScrollLeft;
}
if (mainEl) {
mainEl.classList.toggle('is-scrolled', mainEl.scrollTop > 2);
}
});
} else if (mainEl) {
mainEl.classList.toggle('is-scrolled', mainEl.scrollTop > 2);
}
if (window.lucide) lucide.createIcons();
updateMobileBottomBar();
updatePaneFooter(paneIndex);
}
// ---------------- 🎨 CUSTOM ICON & EMOTE PRESETS & RESOLUTION ----------------
const NERDFONT_PRESET = {
'folder': '',
'dir': '',
'rs': '',
'py': '',
'pyw': '',
'ipynb': '',
'js': '',
'mjs': '',
'cjs': '',
'ts': '',
'tsx': '',
'jsx': '',
'html': '',
'htm': '',
'vue': '',
'svelte': '',
'css': '',
'scss': '',
'less': '',
'json': '',
'toml': '',
'yaml': '',
'yml': '',
'xml': '',
'ini': '',
'env': '',
'conf': '',
'config': '',
'md': '',
'markdown': '',
'go': '',
'c': '',
'cpp': '',
'h': '',
'hpp': '',
'cc': '',
'cxx': '',
'java': '',
'jar': '',
'kt': '',
'kts': '',
'cs': '',
'php': '',
'rb': '',
'lua': '',
'sh': '',
'bash': '',
'zsh': '',
'fish': '',
'bat': '',
'cmd': '',
'ps1': '',
'exe': '',
'msi': '',
'bin': '',
'appimage': '',
'deb': '',
'rpm': '',
'apk': '',
'zip': '',
'tar': '',
'gz': '',
'tgz': '',
'bz2': '',
'xz': '',
'7z': '',
'rar': '',
'iso': '',
'pdf': '',
'doc': '',
'docx': '',
'xls': '',
'xlsx': '',
'csv': '',
'ppt': '',
'pptx': '',
'png': '',
'jpg': '',
'jpeg': '',
'gif': '',
'svg': '',
'webp': '',
'mp3': '',
'flac': '',
'wav': '',
'm4a': '',
'mp4': '',
'mkv': '',
'avi': '',
'mov': '',
'webm': '',
'db': '',
'sql': '',
'sqlite': '',
'sqlite3': '',
'lock': '',
'key': '',
'docker': '',
'git': '',
'ttf': '',
'otf': '',
'woff': '',
'woff2': ''
};
const EMOJI_PRESET = {
'folder': '📁',
'dir': '📁',
'rs': '🦀',
'py': '🐍',
'pyw': '🐍',
'ipynb': '📓',
'js': '⚡',
'mjs': '⚡',
'cjs': '⚡',
'ts': '📘',
'tsx': '⚛️',
'jsx': '⚛️',
'html': '🌐',
'htm': '🌐',
'vue': '🟢',
'svelte': '🟠',
'css': '🎨',
'scss': '🎨',
'json': '⚙️',
'toml': '⚙️',
'yaml': '⚙️',
'yml': '⚙️',
'xml': '📜',
'env': '🔒',
'conf': '🔧',
'config': '🔧',
'md': '📝',
'markdown': '📝',
'go': '🐹',
'c': '🔷',
'cpp': '💎',
'h': '🏷️',
'java': '☕',
'jar': '🫙',
'kt': '🟣',
'cs': '🎯',
'php': '🐘',
'rb': '♦️',
'lua': '🌙',
'sh': '💻',
'bash': '💻',
'zsh': '💻',
'bat': '📜',
'ps1': '🟦',
'exe': '🚀',
'deb': '📦',
'rpm': '📦',
'apk': '📱',
'zip': '📦',
'tar': '📦',
'gz': '📦',
'7z': '📦',
'iso': '💿',
'pdf': '📕',
'doc': '📄',
'docx': '📄',
'xls': '📊',
'xlsx': '📊',
'csv': '📈',
'ppt': '📽️',
'pptx': '📽️',
'png': '🖼️',
'jpg': '🖼️',
'jpeg': '🖼️',
'gif': '🎞️',
'svg': '📐',
'mp3': '🎵',
'flac': '🎼',
'wav': '🎧',
'mp4': '🎬',
'mkv': '🎥',
'avi': '📹',
'db': '🗄️',
'sql': '📊',
'sqlite': '🗄️',
'lock': '🔒',
'key': '🔑',
'ttf': '🔤',
'otf': '🔤'
};
const COMMON_PRESET_ICONS = [
'📁', '📂', '🗂️', '💼', '📦', '🚀', '💡', '🏷️', '🔒', '⭐️',
'🎨', '🧪', '🛠️', '🌐', '🎮', '📱', '🖥️', '🐳', '🐧', '🪟',
'🍎', '🐕', '🦀', '🐍', '⚡', '☕', '💎', '📝', '📊', '📕',
'🎵', '🎬', '🖼️', '💾', '💿', '', '', '', '', '',
'', '', '', '', ''
];
function renderFileIconHtml(name, is_dir, is_archive, path, size = 'sm') {
// 1. Check per-path custom icon (from SQLite / fileTagsMap)
if (path && fileTagsMap.has(path)) {
const itemTag = fileTagsMap.get(path);
if (itemTag && itemTag.custom_icon) {
return formatCustomIconToHtml(itemTag.custom_icon, size, is_dir);
}
}
const ext = (name || '').includes('.') ? (name || '').split('.').pop().toLowerCase() : '';
const lowerName = (name || '').toLowerCase();
// 2. Check per-filetype or extension custom icon from App.customFileIcons
if (is_dir) {
if (App.customFileIcons && (App.customFileIcons['folder'] || App.customFileIcons['dir'])) {
return formatCustomIconToHtml(App.customFileIcons['folder'] || App.customFileIcons['dir'], size, true);
}
if (App.globalFolderIcon) {
return formatCustomIconToHtml(App.globalFolderIcon, size, true);
}
} else {
if (App.customFileIcons && ext && App.customFileIcons[ext]) {
return formatCustomIconToHtml(App.customFileIcons[ext], size, false);
}
if (App.customFileIcons && App.customFileIcons[lowerName]) {
return formatCustomIconToHtml(App.customFileIcons[lowerName], size, false);
}
}
// 3. Check Global Theme if Nerd Font or Emoji mode is active
if (App.iconTheme === 'nerdfont') {
if (is_dir) {
if (lowerName === '.git') return formatCustomIconToHtml('', size, true, '#f97316');
if (lowerName === 'node_modules') return formatCustomIconToHtml('', size, true, '#10b981');
if (lowerName === 'downloads') return formatCustomIconToHtml('', size, true, '#38bdf8');
if (lowerName === 'documents') return formatCustomIconToHtml('', size, true, '#38bdf8');
if (lowerName === 'pictures' || lowerName === 'photos') return formatCustomIconToHtml('', size, true, '#a855f7');
if (lowerName === 'music') return formatCustomIconToHtml('', size, true, '#f43f5e');
if (lowerName === 'videos') return formatCustomIconToHtml('', size, true, '#06b6d4');
return formatCustomIconToHtml(NERDFONT_PRESET['folder'] || '', size, true, '#f59e0b');
}
if (ext && NERDFONT_PRESET[ext]) {
return formatCustomIconToHtml(NERDFONT_PRESET[ext], size, false);
}
} else if (App.iconTheme === 'emoji') {
if (is_dir) {
if (lowerName === 'downloads') return formatCustomIconToHtml('📥', size, true);
if (lowerName === 'documents') return formatCustomIconToHtml('📚', size, true);
if (lowerName === 'pictures' || lowerName === 'photos') return formatCustomIconToHtml('📸', size, true);
if (lowerName === 'music') return formatCustomIconToHtml('🎵', size, true);
if (lowerName === 'videos') return formatCustomIconToHtml('🎬', size, true);
if (lowerName === '.trash' || lowerName === 'trash') return formatCustomIconToHtml('🗑️', size, true);
return formatCustomIconToHtml(EMOJI_PRESET['folder'] || '📁', size, true);
}
if (ext && EMOJI_PRESET[ext]) {
return formatCustomIconToHtml(EMOJI_PRESET[ext], size, false);
}
}
// 4. Default Woofson icon resolution
const iconDetails = getFileIconDetails(name, is_dir, is_archive);
return formatIconDetailsToHtml(iconDetails, size, is_dir);
}
function formatCustomIconToHtml(iconVal, size = 'sm', isDir = false, forceColor = null) {
if (!iconVal) return '';
const clean = iconVal.trim();
if (clean.startsWith('assets/') || clean.startsWith('/') || clean.startsWith('http://') || clean.startsWith('https://') || clean.startsWith('data:image')) {
const dim = size === 'lg' ? '44px' : (size === 'md' ? '28px' : '17px');
return `<img src="${escapeHtml(clean)}" class="file-icon-img" alt="Icon" style="width: ${dim}; height: ${dim}; object-fit: contain;">`;
}
if (clean.startsWith('img:')) {
const src = clean.slice(4).trim();
const dim = size === 'lg' ? '44px' : (size === 'md' ? '28px' : '17px');
return `<img src="${escapeHtml(src)}" class="file-icon-img" alt="Icon" style="width: ${dim}; height: ${dim}; object-fit: contain;">`;
}
if (clean.startsWith('lucide:')) {
const iconName = clean.slice(7).trim();
const dim = size === 'lg' ? '32px' : (size === 'md' ? '22px' : '15px');
const colorStyle = forceColor ? `style="width: ${dim}; height: ${dim}; color: ${forceColor};"` : (isDir ? `style="width: ${dim}; height: ${dim}; color: var(--accent);"` : `style="width: ${dim}; height: ${dim};"`);
return `<i data-lucide="${escapeHtml(iconName)}" class="file-icon" ${colorStyle}></i>`;
}
const sizeClass = size === 'lg' ? 'icon-lg' : (size === 'md' ? 'icon-md' : 'icon-sm');
const colorStyle = forceColor ? `style="color: ${forceColor};"` : '';
return `<span class="file-icon-custom-glyph ${sizeClass}" ${colorStyle}>${escapeHtml(clean)}</span>`;
}
function formatIconDetailsToHtml(iconDetails, size = 'sm', isDir = false) {
if (iconDetails.glyph) {
return formatCustomIconToHtml(iconDetails.glyph, size, isDir, iconDetails.color || (isDir ? 'var(--accent)' : null));
}
if (iconDetails.src) {
const dim = size === 'lg' ? '48px' : (size === 'md' ? '28px' : '17px');
return `<img src="${iconDetails.src}" class="file-icon-img" alt="${isDir ? 'Folder' : 'File'}" style="width: ${dim}; height: ${dim}; object-fit: contain;">`;
}
const dim = size === 'lg' ? '32px' : (size === 'md' ? '22px' : '15px');
const colorStyle = iconDetails.color ? `style="width: ${dim}; height: ${dim}; color: ${iconDetails.color};"` : `style="width: ${dim}; height: ${dim};"`;
return `<i data-lucide="${iconDetails.icon}" class="file-icon ${iconDetails.type || ''}" ${colorStyle}></i>`;
}
function getFileIconDetails(name, is_dir, is_archive) {
if (is_dir) {
const lower = (name || '').toLowerCase();
if (lower === '.git') return { icon: 'git-branch', type: 'git', color: '#f97316' };
if (lower === 'node_modules') return { icon: 'package', type: 'package', color: '#10b981' };
if (lower === 'downloads') return { icon: 'download', type: 'folder', color: '#38bdf8' };
if (lower === 'documents') return { icon: 'file-text', type: 'folder', color: '#38bdf8' };
if (lower === 'pictures' || lower === 'photos') return { icon: 'image', type: 'folder', color: '#a855f7' };
if (lower === 'music') return { icon: 'music', type: 'folder', color: '#f43f5e' };
if (lower === 'videos') return { icon: 'video', type: 'folder', color: '#06b6d4' };
if (lower === '.trash' || lower === 'trash') return { icon: 'trash-2', type: 'folder', color: '#ef4444' };
if (lower === 'desktop') return { icon: 'monitor', type: 'folder', color: '#f59e0b' };
return { icon: 'folder', type: 'folder', color: 'var(--accent)' };
}
if (isVaultFile(name)) {
return { icon: 'shield-check', type: 'vault', color: '#f59e0b' };
}
const ext = (name || '').split('.').pop().toLowerCase();
// Optical & Disk Sector Images
if (['iso', 'udf', 'img', 'raw', 'dd', 'vhd'].includes(ext)) {
return { icon: 'disc', type: 'disc', color: '#38bdf8' };
}
// SquashFS & Compressed System Images
if (['squashfs', 'snap'].includes(ext)) {
return { icon: 'box', type: 'archive', color: '#f59e0b' };
}
// Archives & Packages
if (is_archive || ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'img', 'vhd', 'deb', 'rpm', 'apk', 'pkg', 'zst'].includes(ext)) {
return { icon: 'file-archive', type: 'archive', color: '#f59e0b' };
}
// Executables & Binaries
if (['exe', 'msi', 'bat', 'cmd', 'ps1', 'sh', 'bash', 'bin', 'appimage', 'elf', 'dylib', 'so', 'dll'].includes(ext)) {
return { icon: 'terminal', type: 'executable', color: '#10b981' };
}
// Code & Developer Formats
if (ext === 'rs') return { icon: 'code', type: 'code', color: '#ef4444' };
if (['py', 'pyw', 'ipynb'].includes(ext)) return { icon: 'code', type: 'code', color: '#38bdf8' };
if (['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx'].includes(ext)) return { icon: 'code', type: 'code', color: '#eab308' };
if (['html', 'htm', 'vue', 'svelte'].includes(ext)) return { icon: 'globe', type: 'web', color: '#f97316' };
if (['css', 'scss', 'less'].includes(ext)) return { icon: 'palette', type: 'style', color: '#38bdf8' };
if (['c', 'cpp', 'h', 'hpp', 'cc', 'cxx'].includes(ext)) return { icon: 'cpu', type: 'c-code', color: '#6366f1' };
if (ext === 'go') return { icon: 'code', type: 'go', color: '#06b6d4' };
if (['java', 'jar', 'kt', 'kts'].includes(ext)) return { icon: 'coffee', type: 'java', color: '#f97316' };
if (['cs', 'vb', 'fs'].includes(ext)) return { icon: 'code', type: 'dotnet', color: '#a855f7' };
if (['php', 'rb', 'gem', 'lua', 'pl', 'r', 'swift', 'dart'].includes(ext)) return { icon: 'code', type: 'script', color: '#ec4899' };
if (['sql', 'sqlite', 'sqlite3', 'db', 'mdb', 'accdb'].includes(ext)) return { icon: 'database', type: 'db', color: '#14b8a6' };
if (['json', 'toml', 'yaml', 'yml', 'xml', 'ini', 'env', 'conf', 'config'].includes(ext)) return { icon: 'sliders', type: 'config', color: '#e2e8f0' };
// Documents & Spreadsheets
if (ext === 'pdf') return { icon: 'file-text', type: 'pdf', color: '#ef4444' };
if (['md', 'markdown'].includes(ext)) return { icon: 'book-open', type: 'markdown', color: '#c084fc' };
if (['doc', 'docx', 'odt', 'rtf', 'pages'].includes(ext)) return { icon: 'file-text', type: 'doc', color: '#3b82f6' };
if (['xls', 'xlsx', 'ods', 'csv', 'tsv', 'tab'].includes(ext)) return { icon: 'table', type: 'sheet', color: '#10b981' };
if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) return { icon: 'presentation', type: 'presentation', color: '#f97316' };
// Images & 3D
if (isImageExtension(name)) return { icon: 'image', type: 'image', color: '#10b981' };
if (['stl', 'obj', 'blend', 'fbx', 'gltf', 'glb', '3ds', 'step', 'stp'].includes(ext)) return { icon: 'box', type: '3d', color: '#06b6d4' };
if (['psd', 'ai', 'eps', 'sketch', 'fig', 'xcf'].includes(ext)) return { icon: 'palette', type: 'design', color: '#a855f7' };
// Media
if (isAudioExtension(name)) return { icon: 'music', type: 'audio', color: '#f43f5e' };
if (isVideoExtension(name)) return { icon: 'video', type: 'video', color: '#38bdf8' };
if (isComicBookExtension(name)) return { icon: 'book', type: 'comic', color: '#f59e0b' };
// Fonts & Certificates
if (['ttf', 'otf', 'woff', 'woff2', 'eot'].includes(ext)) return { icon: 'type', type: 'font', color: '#94a3b8' };
if (['pem', 'crt', 'cer', 'key', 'pub', 'asc', 'gpg', 'sig', 'kdbx'].includes(ext)) return { icon: 'key', type: 'crypto', color: '#f59e0b' };
return { icon: 'file-text', type: 'text', color: '#94a3b8' };
}
function updatePaneFooter(paneIndex, data) {
const pane = App.panes[paneIndex];
const footer = document.getElementById(`pane-footer-${paneIndex}`);
if (!footer || !pane) return;
if (data) {
pane.lastTotalDirs = data.total_dirs;
pane.lastTotalFiles = data.total_files;
pane.lastTotalSize = data.total_size;
}
const countsEl = document.getElementById(`pane-footer-counts-${paneIndex}`);
const sizeEl = document.getElementById(`pane-footer-size-${paneIndex}`);
if (pane.selected && pane.selected.size > 0) {
let selFiles = 0;
let selDirs = 0;
let selBytes = 0;
pane.entries.forEach(e => {
if (pane.selected.has(e.path)) {
if (e.is_dir) selDirs++;
else {
selFiles++;
selBytes += (e.size || 0);
}
}
});
const totalCount = pane.entries ? pane.entries.length : 0;
const selCount = pane.selected.size;
const countsText = `⚡ ${selCount}/${totalCount} selected (${selDirs > 0 ? `${selDirs} dir${selDirs > 1 ? 's' : ''}, ` : ''}${selFiles} file${selFiles !== 1 ? 's' : ''})`;
const sizeText = `Selected: ${formatBytes(selBytes)} (of ${formatBytes(pane.lastTotalSize || 0)})`;
if (countsEl) {
countsEl.textContent = countsText;
countsEl.style.color = 'var(--accent)';
countsEl.style.fontWeight = '700';
}
if (sizeEl) {
sizeEl.textContent = sizeText;
sizeEl.style.color = 'var(--accent)';
sizeEl.style.fontWeight = '700';
}
} else {
const totalDirs = pane.lastTotalDirs ?? 0;
const totalFiles = pane.lastTotalFiles ?? 0;
const totalSize = pane.lastTotalSize ?? 0;
const countsText = `${totalDirs} dirs, ${totalFiles} files`;
const sizeText = `Total: ${formatBytes(totalSize)}`;
if (countsEl) {
countsEl.textContent = countsText;
countsEl.style.color = '';
countsEl.style.fontWeight = '';
}
if (sizeEl) {
sizeEl.textContent = sizeText;
sizeEl.style.color = '';
sizeEl.style.fontWeight = '';
}
}
}
// ---------------- PERMISSIONS MANAGER (CHMOD / CHOWN) ----------------
let activePermEntry = null;
function triggerPermissions() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || pane.entries[pane.cursorIndex];
if (!item) return;
activePermEntry = item;
document.getElementById('perm-target-name').textContent = item.name;
document.getElementById('perm-target-path').textContent = item.path;
// Parse mode octal or permissions
let mode = 0o755;
if (item.mode_octal) {
mode = parseInt(item.mode_octal, 8);
}
setPermCheckboxesFromMode(mode);
populateOwnerGroupDropdowns(item.owner, item.group);
showModal('permissions-modal');
}
function setPermCheckboxesFromMode(mode) {
// Owner (u)
document.getElementById('perm-u-r').checked = (mode & 0o400) !== 0;
document.getElementById('perm-u-w').checked = (mode & 0o200) !== 0;
document.getElementById('perm-u-x').checked = (mode & 0o100) !== 0;
// Group (g)
document.getElementById('perm-g-r').checked = (mode & 0o040) !== 0;
document.getElementById('perm-g-w').checked = (mode & 0o020) !== 0;
document.getElementById('perm-g-x').checked = (mode & 0o010) !== 0;
// Others (o)
document.getElementById('perm-o-r').checked = (mode & 0o004) !== 0;
document.getElementById('perm-o-w').checked = (mode & 0o002) !== 0;
document.getElementById('perm-o-x').checked = (mode & 0o001) !== 0;
document.getElementById('perm-octal-display').textContent = '0' + (mode & 0o777).toString(8);
}
function updatePermFromCheckboxes() {
let mode = 0;
if (document.getElementById('perm-u-r').checked) mode |= 0o400;
if (document.getElementById('perm-u-w').checked) mode |= 0o200;
if (document.getElementById('perm-u-x').checked) mode |= 0o100;
if (document.getElementById('perm-g-r').checked) mode |= 0o040;
if (document.getElementById('perm-g-w').checked) mode |= 0o020;
if (document.getElementById('perm-g-x').checked) mode |= 0o010;
if (document.getElementById('perm-o-r').checked) mode |= 0o004;
if (document.getElementById('perm-o-w').checked) mode |= 0o002;
if (document.getElementById('perm-o-x').checked) mode |= 0o001;
document.getElementById('perm-octal-display').textContent = '0' + (mode & 0o777).toString(8);
}
function applyPermPreset(presetMode) {
setPermCheckboxesFromMode(presetMode);
}
function populateOwnerGroupDropdowns(currentOwner, currentGroup) {
const uSel = document.getElementById('perm-owner-select');
const gSel = document.getElementById('perm-group-select');
uSel.innerHTML = '';
gSel.innerHTML = '';
App.systemUsers.forEach(u => {
const opt = document.createElement('option');
opt.value = u;
opt.textContent = u;
if (u === currentOwner) opt.selected = true;
uSel.appendChild(opt);
});
App.systemGroups.forEach(g => {
const opt = document.createElement('option');
opt.value = g;
opt.textContent = g;
if (g === currentGroup) opt.selected = true;
gSel.appendChild(opt);
});
}
document.getElementById('btn-save-permissions')?.addEventListener('click', async () => {
if (!activePermEntry) return;
const modeStr = document.getElementById('perm-octal-display').textContent;
const modeVal = parseInt(modeStr, 8);
const recursive = document.getElementById('perm-recursive').checked;
const owner = document.getElementById('perm-owner-select').value;
const group = document.getElementById('perm-group-select').value;
const paths = [activePermEntry.path];
// 1. Chmod
await fetch('/api/fs/chmod', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ paths, mode: modeVal, recursive })
});
// 2. Chown
await fetch('/api/fs/chown', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ paths, owner, group, recursive })
});
closeModal('permissions-modal');
refreshAllPanes();
});
// ---------------- RESIZABLE COLUMNS, VIEW MODES & DIRECTORY TREE ----------------
function initColResize(e, paneIndex, colKey) {
if (e.cancelable) e.preventDefault();
e.stopPropagation();
const th = document.getElementById(`col-header-${paneIndex}-${colKey}`);
if (!th) return;
const resizer = e.currentTarget || e.target;
if (resizer) {
resizer.classList.add('is-resizing');
if (resizer.setPointerCapture && e.pointerId !== undefined) {
try { resizer.setPointerCapture(e.pointerId); } catch (_) {}
}
}
const getClientX = (ev) => {
if (ev.clientX !== undefined && ev.clientX !== 0) return ev.clientX;
if (ev.touches && ev.touches.length > 0) return ev.touches[0].clientX;
if (ev.changedTouches && ev.changedTouches.length > 0) return ev.changedTouches[0].clientX;
return 0;
};
const startX = getClientX(e);
const startWidth = th.getBoundingClientRect().width || th.offsetWidth;
document.body.classList.add('is-col-resizing');
const onPointerMove = (moveEvent) => {
if (moveEvent.cancelable) moveEvent.preventDefault();
moveEvent.stopPropagation();
const currentX = getClientX(moveEvent);
if (!currentX) return;
const diff = currentX - startX;
const newWidth = Math.max(28, Math.round(startWidth + diff));
applyColumnWidth(colKey, newWidth);
};
const onPointerUp = (upEvent) => {
if (upEvent && upEvent.cancelable) upEvent.preventDefault();
if (upEvent) upEvent.stopPropagation();
document.body.classList.remove('is-col-resizing');
if (resizer) {
resizer.classList.remove('is-resizing');
if (resizer.releasePointerCapture && upEvent && upEvent.pointerId !== undefined) {
try { resizer.releasePointerCapture(upEvent.pointerId); } catch (_) {}
}
}
window.removeEventListener('pointermove', onPointerMove, { capture: true });
window.removeEventListener('pointerup', onPointerUp, { capture: true });
window.removeEventListener('pointercancel', onPointerUp, { capture: true });
window.removeEventListener('touchmove', onPointerMove, { capture: true });
window.removeEventListener('touchend', onPointerUp, { capture: true });
window.removeEventListener('touchcancel', onPointerUp, { capture: true });
window.removeEventListener('mousemove', onPointerMove, { capture: true });
window.removeEventListener('mouseup', onPointerUp, { capture: true });
document.removeEventListener('pointermove', onPointerMove, { capture: true });
document.removeEventListener('pointerup', onPointerUp, { capture: true });
document.removeEventListener('touchmove', onPointerMove, { capture: true });
document.removeEventListener('touchend', onPointerUp, { capture: true });
const finalWidth = Math.round(th.getBoundingClientRect().width || th.offsetWidth);
saveColumnWidth(colKey, finalWidth);
};
window.addEventListener('pointermove', onPointerMove, { capture: true, passive: false });
window.addEventListener('pointerup', onPointerUp, { capture: true, passive: false });
window.addEventListener('pointercancel', onPointerUp, { capture: true, passive: false });
window.addEventListener('touchmove', onPointerMove, { capture: true, passive: false });
window.addEventListener('touchend', onPointerUp, { capture: true, passive: false });
window.addEventListener('touchcancel', onPointerUp, { capture: true, passive: false });
window.addEventListener('mousemove', onPointerMove, { capture: true, passive: false });
window.addEventListener('mouseup', onPointerUp, { capture: true, passive: false });
document.addEventListener('pointermove', onPointerMove, { capture: true, passive: false });
document.addEventListener('pointerup', onPointerUp, { capture: true, passive: false });
document.addEventListener('touchmove', onPointerMove, { capture: true, passive: false });
document.addEventListener('touchend', onPointerUp, { capture: true, passive: false });
}
function applyColumnWidth(colKey, width) {
ColumnConfig.widths[colKey] = width;
for (let i = 0; i < 4; i++) {
const table = document.getElementById(`pane-table-${i}`);
if (table) {
table.style.setProperty(`--col-${colKey}-w`, `${width}px`);
}
const th = document.getElementById(`col-header-${i}-${colKey}`);
if (th) {
th.style.setProperty('width', `${width}px`, 'important');
th.style.setProperty('min-width', `${Math.min(width, 28)}px`, 'important');
th.style.setProperty('max-width', `${width}px`, 'important');
}
}
}
function saveColumnWidth(colKey, width) {
ColumnConfig.widths[colKey] = width;
localStorage.setItem('cd_col_widths', JSON.stringify(ColumnConfig.widths));
queueSaveUserPreferencesToServer();
}
function autoFitColumn(paneIndex, colKey) {
const table = document.getElementById(`pane-table-${paneIndex}`);
if (!table) return;
const th = document.getElementById(`col-header-${paneIndex}-${colKey}`);
if (!th) return;
const colIdx = Array.from(th.parentElement.children).indexOf(th);
if (colIdx < 0) return;
let maxW = th.scrollWidth + 24;
const rows = table.querySelectorAll('tbody tr');
rows.forEach(r => {
const cell = r.children[colIdx];
if (cell) {
maxW = Math.max(maxW, cell.scrollWidth + 18);
}
});
const fittedWidth = Math.min(Math.max(45, maxW), 600);
applyColumnWidth(colKey, fittedWidth);
saveColumnWidth(colKey, fittedWidth);
}
function autoFitAllColumns(paneIndex) {
Object.keys(ColumnConfig.visibility).forEach(colKey => {
if (ColumnConfig.visibility[colKey]) {
autoFitColumn(paneIndex, colKey);
}
});
showToast('All visible columns auto-fitted', 'success');
}
function applyAllColumnWidths() {
const isPhone = window.innerWidth <= 600;
const isTablet = window.innerWidth > 600 && window.innerWidth <= 1024;
const widths = ColumnConfig.widths || {};
const vis = ColumnConfig.visibility || {};
for (let i = 0; i < 4; i++) {
const table = document.getElementById(`pane-table-${i}`);
// On Phone (<= 600px) and Tablet/Foldable (601px - 1024px), do NOT apply fixed desktop pixel widths
if (isPhone || isTablet) {
if (table) {
['name', 'ext', 'size', 'modified', 'created', 'mode', 'owner', 'group', 'hash', 'tags'].forEach(k => {
table.style.removeProperty(`--col-${k}-w`);
});
}
const ths = document.querySelectorAll(`#pane-table-${i} th`);
ths.forEach(th => {
th.style.removeProperty('width');
th.style.removeProperty('min-width');
th.style.removeProperty('max-width');
});
if (isPhone) {
// On Phone: only show icon, name, size, modified; explicitly hide secondary columns
const phoneVisibleCols = new Set(['icon', 'name', 'size', 'modified']);
['icon', 'name', 'ext', 'size', 'modified', 'created', 'mode', 'owner', 'group', 'hash', 'tags'].forEach(colKey => {
const th = document.getElementById(`col-header-${i}-${colKey}`);
if (th) {
th.style.display = phoneVisibleCols.has(colKey) ? '' : 'none';
}
});
} else {
// On Tablet: apply user visibility toggles without fixed desktop pixel widths
for (const [colKey, isVis] of Object.entries(vis)) {
const th = document.getElementById(`col-header-${i}-${colKey}`);
if (th) th.style.display = isVis ? '' : 'none';
}
}
continue;
}
// Full PC / Desktop Viewport (> 1024px): apply user customized column widths
for (const [colKey, w] of Object.entries(widths)) {
if (table && w) {
table.style.setProperty(`--col-${colKey}-w`, `${w}px`);
}
const th = document.getElementById(`col-header-${i}-${colKey}`);
if (th && w) {
th.style.setProperty('width', `${w}px`);
th.style.setProperty('min-width', `${Math.min(w, 28)}px`);
th.style.setProperty('max-width', `${w}px`);
}
}
for (const [colKey, isVis] of Object.entries(vis)) {
const th = document.getElementById(`col-header-${i}-${colKey}`);
if (th) th.style.display = isVis ? '' : 'none';
}
}
}
function toggleColumnVisibility(colKey, visible) {
ColumnConfig.visibility[colKey] = visible;
localStorage.setItem('cd_col_visibility', JSON.stringify(ColumnConfig.visibility));
applyAllColumnWidths();
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
}
function resetAllColumnWidths() {
ColumnConfig.widths = {};
ColumnConfig.visibility = { name: true, ext: false, size: true, modified: true, created: false, mode: true, owner: true, group: false, hash: false, tags: false };
localStorage.removeItem('cd_col_widths');
localStorage.removeItem('cd_col_visibility');
for (let i = 0; i < 4; i++) {
const table = document.getElementById(`pane-table-${i}`);
if (table) {
['name', 'ext', 'size', 'modified', 'created', 'mode', 'owner', 'group', 'hash', 'tags'].forEach(k => {
table.style.removeProperty(`--col-${k}-w`);
});
}
const ths = document.querySelectorAll(`#pane-table-${i} th`);
ths.forEach(th => {
th.style.removeProperty('width');
th.style.removeProperty('min-width');
th.style.removeProperty('max-width');
});
}
applyAllColumnWidths();
updateColumnCheckboxes();
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
showToast('Column widths and visibility reset to default', 'info');
}
function updateColumnCheckboxes() {
['name', 'ext', 'size', 'modified', 'created', 'mode', 'owner', 'group', 'hash', 'tags'].forEach(k => {
const el = document.getElementById(`col-toggle-${k}`);
if (el) el.checked = !!ColumnConfig.visibility[k];
});
const stickyEl = document.getElementById('setting-sticky-col-headers');
if (stickyEl) stickyEl.checked = localStorage.getItem('cd_sticky_col_headers') !== '0';
const sepEl = document.getElementById('setting-col-separators');
if (sepEl) sepEl.checked = localStorage.getItem('cd_col_separators') !== '0';
const dirEl = document.getElementById('setting-show-dir-tag');
if (dirEl) dirEl.checked = localStorage.getItem('cd_show_dir_tag') === '1';
const dateEl = document.getElementById('setting-compact-dates');
if (dateEl) dateEl.checked = localStorage.getItem('cd_compact_dates') !== '0';
const multiEl = document.getElementById('setting-file-list-multiline');
if (multiEl) multiEl.checked = localStorage.getItem('cd_file_list_multiline') === '1';
}
function toggleStickyColHeaders(enable) {
localStorage.setItem('cd_sticky_col_headers', enable ? '1' : '0');
applyStickyColHeaders();
updateColumnCheckboxes();
queueSaveUserPreferencesToServer();
}
function applyStickyColHeaders() {
const isSticky = localStorage.getItem('cd_sticky_col_headers') !== '0';
document.body.classList.toggle('unsticky-col-headers', !isSticky);
}
function toggleColSeparators(enable) {
localStorage.setItem('cd_col_separators', enable ? '1' : '0');
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
}
function toggleShowDirTag(show) {
localStorage.setItem('cd_show_dir_tag', show ? '1' : '0');
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
}
function toggleFileListMultiline(enable) {
localStorage.setItem('cd_file_list_multiline', enable ? '1' : '0');
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
}
function toggleCompactDates(enable) {
localStorage.setItem('cd_compact_dates', enable ? '1' : '0');
for (let i = 0; i < 4; i++) {
renderPaneTable(i);
}
queueSaveUserPreferencesToServer();
}
function openColumnHeaderContextMenu(e, paneIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const existing = document.getElementById('col-chooser-popover');
const wasOpenForThisPane = existing && existing.dataset.paneIndex === String(paneIndex);
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
if (wasOpenForThisPane) return;
const pop = document.createElement('div');
pop.id = 'col-chooser-popover';
pop.dataset.paneIndex = String(paneIndex);
pop.className = 'col-chooser-popover';
const colDefinitions = [
{ key: 'name', label: 'File Name', locked: true },
{ key: 'ext', label: 'Extension (.ext)' },
{ key: 'size', label: 'File Size' },
{ key: 'modified', label: 'Date Modified' },
{ key: 'created', label: 'Date Created' },
{ key: 'mode', label: 'Mode (Permissions)' },
{ key: 'owner', label: 'Owner' },
{ key: 'group', label: 'Group' },
{ key: 'hash', label: 'SHA-256 Checksum' },
{ key: 'tags', label: 'Tags & Colors' },
];
const isStickyColHeaders = localStorage.getItem('cd_sticky_col_headers') !== '0';
const showColSeparators = localStorage.getItem('cd_col_separators') !== '0';
const showDirTag = localStorage.getItem('cd_show_dir_tag') === '1';
const isMultiLine = localStorage.getItem('cd_file_list_multiline') === '1';
const isCompactDates = localStorage.getItem('cd_compact_dates') !== '0';
let itemsHtml = `<div class="col-chooser-title">Table Columns</div>`;
colDefinitions.forEach(col => {
const isChecked = col.locked || !!ColumnConfig.visibility[col.key];
const disabledAttr = col.locked ? 'disabled' : '';
itemsHtml += `
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${isChecked ? 'checked' : ''} ${disabledAttr} onchange="toggleColumnVisibility('${col.key}', this.checked)">
<span>${escapeHtml(col.label)}</span>
</label>
`;
});
itemsHtml += `
<div style="height: 1px; background: var(--border); margin: 6px 0;"></div>
<div class="col-chooser-title" style="margin-top: 2px;">Display Options</div>
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${isStickyColHeaders ? 'checked' : ''} onchange="toggleStickyColHeaders(this.checked)">
<span>Sticky header (Frosted Glass)</span>
</label>
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${showColSeparators ? 'checked' : ''} onchange="toggleColSeparators(this.checked)">
<span>Discrete column separators</span>
</label>
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${showDirTag ? 'checked' : ''} onchange="toggleShowDirTag(this.checked)">
<span>Show classic <code><DIR></code> tag</span>
</label>
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${isCompactDates ? 'checked' : ''} onchange="toggleCompactDates(this.checked)">
<span>Compact date format</span>
</label>
<label class="col-chooser-item" onclick="event.stopPropagation()">
<input type="checkbox" ${isMultiLine ? 'checked' : ''} onchange="toggleFileListMultiline(this.checked)">
<span>Multi-line subtext (Mobile)</span>
</label>
<div style="height: 1px; background: var(--border); margin: 6px 0;"></div>
<div class="col-chooser-action-btn" onclick="autoFitAllColumns(${paneIndex}); document.getElementById('col-chooser-popover')?.remove();">
<i data-lucide="move-horizontal" style="width: 13px;"></i> Auto-Fit All Columns
</div>
<div class="col-chooser-action-btn" onclick="resetAllColumnWidths(); document.getElementById('col-chooser-popover')?.remove();">
<i data-lucide="rotate-ccw" style="width: 13px;"></i> Reset Default Columns
</div>
`;
pop.innerHTML = itemsHtml;
document.body.appendChild(pop);
if (window.lucide) lucide.createIcons({ root: pop });
const popW = 220;
const popH = pop.offsetHeight || 340;
let posX = e.clientX;
let posY = e.clientY;
if (posX + popW > window.innerWidth) posX = window.innerWidth - popW - 10;
if (posY + popH > window.innerHeight) posY = window.innerHeight - popH - 10;
pop.style.left = `${Math.max(10, posX)}px`;
pop.style.top = `${Math.max(10, posY)}px`;
const dismissPopover = (ev) => {
if (!pop.contains(ev.target)) {
pop.remove();
document.removeEventListener('click', dismissPopover);
document.removeEventListener('touchstart', dismissPopover);
}
};
setTimeout(() => {
document.addEventListener('click', dismissPopover);
document.addEventListener('touchstart', dismissPopover);
}, 10);
}
function setPaneViewMode(paneIndex, mode) {
const pane = App.panes[paneIndex];
if (!pane) return;
pane.viewMode = mode;
localStorage.setItem(`cd_pane_viewmode_${paneIndex}`, mode);
['details', 'grid', 'compact'].forEach(m => {
const btn = document.getElementById(`btn-view-${m}-${paneIndex}`);
if (btn) {
if (m === mode) btn.classList.add('active');
else btn.classList.remove('active');
}
});
renderPaneTable(paneIndex);
queueSaveUserPreferencesToServer();
}
function setPaneGridSize(paneIndex, size) {
const pane = App.panes[paneIndex];
if (!pane) return;
pane.gridSize = size;
localStorage.setItem(`cd_pane_gridsize_${paneIndex}`, size);
const gridEl = document.getElementById(`pane-grid-${paneIndex}`);
if (gridEl) {
gridEl.className = `grid-gallery-container size-${size}`;
}
queueSaveUserPreferencesToServer();
}
function togglePaneTree(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
pane.showTree = !pane.showTree;
localStorage.setItem(`cd_pane_tree_${paneIndex}`, pane.showTree ? '1' : '0');
const sidebar = document.getElementById(`pane-tree-${paneIndex}`);
const resizer = document.getElementById(`pane-tree-resizer-${paneIndex}`);
const btn = document.getElementById(`btn-tree-${paneIndex}`);
if (sidebar) sidebar.style.display = pane.showTree ? 'flex' : 'none';
if (resizer) resizer.style.display = pane.showTree ? 'block' : 'none';
if (btn) {
if (pane.showTree) btn.classList.add('active');
else btn.classList.remove('active');
}
if (pane.showTree) {
loadPaneDirectoryTree(paneIndex);
}
queueSaveUserPreferencesToServer();
}
async function loadPaneDirectoryTree(paneIndex) {
const sidebar = document.getElementById(`pane-tree-${paneIndex}`);
if (!sidebar) return;
sidebar.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; border-bottom: 1px solid var(--border); margin-bottom: 4px;">
<span style="font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase;">Folders</span>
<button class="btn btn-icon btn-xs" onclick="loadPaneDirectoryTree(${paneIndex})" title="Refresh Tree" style="padding: 2px; width: 18px; height: 18px; border: none; background: transparent;"><i data-lucide="rotate-cw" style="width: 11px; height: 11px;"></i></button>
</div>
<div id="pane-tree-roots-${paneIndex}" style="display: flex; flex-direction: column; gap: 2px;"></div>
`;
const rootsContainer = document.getElementById(`pane-tree-roots-${paneIndex}`);
const homePath = getUserDefaultHomeDir() || '~';
const isWindows = App.systemStatus?.os === 'windows' || Boolean(homePath && homePath.match(/^[a-zA-Z]:/));
const roots = [
{ name: 'Home', path: homePath, icon: 'home' },
];
if (!isWindows) {
roots.push({ name: 'Root', path: '/', icon: 'hard-drive' });
}
if (App.storageRoots && App.storageRoots.length > 0) {
App.storageRoots.forEach(r => {
if (r.path !== '/' && r.path !== homePath) {
const icon = r.path.match(/^[a-zA-Z]:/) ? 'hard-drive' : 'server';
roots.push({ name: r.name, path: r.path, icon });
}
});
} else if (isWindows) {
// If no storage roots returned yet on Windows, fallback to primary drive
const driveMatch = homePath.match(/^([a-zA-Z]:\\?)/);
const primaryDrive = driveMatch ? driveMatch[1] : 'C:\\';
if (!roots.some(r => r.path.toUpperCase().startsWith(primaryDrive.toUpperCase()))) {
roots.push({ name: `Local Disk (${primaryDrive.substring(0, 2)})`, path: primaryDrive.endsWith('\\') ? primaryDrive : primaryDrive + '\\', icon: 'hard-drive' });
}
}
roots.forEach(root => {
const nodeEl = createTreeNodeElement(paneIndex, root.name, root.path, root.icon);
rootsContainer.appendChild(nodeEl);
});
if (window.lucide) lucide.createIcons({ root: sidebar });
}
function createTreeNodeElement(paneIndex, name, path, iconName = 'folder') {
const wrapper = document.createElement('div');
wrapper.className = 'tree-node-item';
wrapper.dataset.path = path;
const isActive = App.panes[paneIndex]?.path === path;
const treeIconHtml = renderFileIconHtml(name, true, false, path, 'sm');
const row = document.createElement('div');
row.className = `tree-node-row ${isActive ? 'active' : ''}`;
row.innerHTML = `
<span class="tree-expander" onclick="event.stopPropagation(); toggleTreeNodeExpand(this, ${paneIndex}, '${escapeHtml(path)}')">
<i data-lucide="chevron-right" style="width: 12px; height: 12px;"></i>
</span>
${treeIconHtml}
<span class="tree-node-label" title="${escapeHtml(path)}">${escapeHtml(name)}</span>
`;
row.onclick = () => {
loadPaneDirectory(paneIndex, path);
const sidebar = document.getElementById(`pane-tree-${paneIndex}`);
if (sidebar) {
sidebar.querySelectorAll('.tree-node-row').forEach(r => r.classList.remove('active'));
row.classList.add('active');
}
};
const childrenContainer = document.createElement('div');
childrenContainer.className = 'tree-node-children';
wrapper.appendChild(row);
wrapper.appendChild(childrenContainer);
return wrapper;
}
async function toggleTreeNodeExpand(expanderEl, paneIndex, path) {
const wrapper = expanderEl.closest('.tree-node-item');
if (!wrapper) return;
const childrenContainer = wrapper.querySelector('.tree-node-children');
if (!childrenContainer) return;
const isExpanded = childrenContainer.classList.contains('expanded');
if (isExpanded) {
childrenContainer.classList.remove('expanded');
expanderEl.classList.remove('expanded');
} else {
childrenContainer.classList.add('expanded');
expanderEl.classList.add('expanded');
if (childrenContainer.children.length === 0) {
childrenContainer.innerHTML = '<div style="font-size: 10px; color: var(--text-muted); padding: 2px 6px;">Loading...</div>';
try {
const resp = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
childrenContainer.innerHTML = '';
const subdirs = (data.entries || []).filter(e => e.is_dir && !e.name.startsWith('.'));
if (subdirs.length === 0) {
childrenContainer.innerHTML = '<div style="font-size: 10px; color: var(--text-dim); padding: 2px 6px; font-style: italic;">(No subfolders)</div>';
} else {
subdirs.sort((a, b) => a.name.localeCompare(b.name));
subdirs.forEach(sub => {
const childNode = createTreeNodeElement(paneIndex, sub.name, sub.path, 'folder');
childrenContainer.appendChild(childNode);
});
if (window.lucide) lucide.createIcons({ root: childrenContainer });
}
} else {
childrenContainer.innerHTML = '<div style="font-size: 10px; color: var(--danger); padding: 2px 6px;">Failed to load</div>';
}
} catch (e) {
childrenContainer.innerHTML = '<div style="font-size: 10px; color: var(--danger); padding: 2px 6px;">Error</div>';
}
}
}
}
function initTreeResize(e, paneIndex) {
e.preventDefault();
e.stopPropagation();
const sidebar = document.getElementById(`pane-tree-${paneIndex}`);
if (!sidebar) return;
const startX = e.clientX;
const startWidth = sidebar.offsetWidth;
document.body.classList.add('is-col-resizing');
const onMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const newWidth = Math.min(Math.max(120, startWidth + diff), 450);
sidebar.style.width = `${newWidth}px`;
};
const onMouseUp = () => {
document.body.classList.remove('is-col-resizing');
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
localStorage.setItem(`cd_tree_width_${paneIndex}`, sidebar.offsetWidth);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function syncPaneTreeActiveNode(paneIndex, path) {
const sidebar = document.getElementById(`pane-tree-${paneIndex}`);
if (!sidebar) return;
sidebar.querySelectorAll('.tree-node-row').forEach(row => {
const parentItem = row.closest('.tree-node-item');
if (parentItem && parentItem.dataset.path === path) {
row.classList.add('active');
} else {
row.classList.remove('active');
}
});
}
function setDefaultViewMode(mode) {
App.defaultViewMode = mode;
localStorage.setItem('cd_default_viewmode', mode);
for (let i = 0; i < 4; i++) {
if (App.panes[i]) {
setPaneViewMode(i, mode);
}
}
}
// ---------------- OPEN WITH & EXTERNAL HANDLERS ----------------
let OpenWithRules = JSON.parse(localStorage.getItem('cd_openwith_rules') || 'null');
if (!OpenWithRules) {
OpenWithRules = [
{ id: 'vlc', name: 'VLC Media Player', exts: ['mp4', 'mkv', 'avi', 'webm', 'mov', 'mp3', 'flac', 'wav'], cmd: 'vlc "%1"', icon: 'film' },
{ id: 'code', name: 'VS Code', exts: ['rs', 'js', 'ts', 'py', 'json', 'toml', 'md', 'txt', 'html', 'css', 'sh', 'c', 'cpp'], cmd: 'code "%1"', icon: 'code' },
{ id: 'default', name: 'System Default Application', exts: ['*'], cmd: '', icon: 'external-link' },
];
}
function renderOpenWithRules() {
const list = document.getElementById('openwith-rules-list');
if (!list) return;
if (OpenWithRules.length === 0) {
list.innerHTML = '<div style="color: var(--text-muted); font-size: 12px; padding: 12px 0;">No external handlers configured yet. Click "Add Handler" above.</div>';
return;
}
list.innerHTML = OpenWithRules.map((r, idx) => `
<div class="rule-config-row">
<i data-lucide="${r.icon || 'external-link'}" style="width: 16px; color: var(--accent);"></i>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 12px;">${escapeHtml(r.name)}</div>
<div style="display: flex; gap: 6px; align-items: center; margin-top: 2px; flex-wrap: wrap;">
<span class="rule-pill">${escapeHtml(r.exts.join(', '))}</span>
<span style="font-family: var(--font-mono); font-size: 11px; color: var(--text-muted);">${escapeHtml(r.cmd || 'System Default')}</span>
</div>
</div>
<button class="btn btn-icon btn-sm" onclick="editOpenWithRule(${idx})" title="Edit"><i data-lucide="edit-3" style="width: 13px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="deleteOpenWithRule(${idx})" title="Delete"><i data-lucide="trash-2" style="width: 13px; color: var(--danger);"></i></button>
</div>
`).join('');
if (window.lucide) lucide.createIcons();
}
function showAddOpenWithRulePrompt() {
document.getElementById('openwith-edit-id').value = '';
document.getElementById('openwith-input-name').value = '';
document.getElementById('openwith-input-exts').value = '';
document.getElementById('openwith-input-cmd').value = '';
document.getElementById('openwith-rule-form').style.display = 'block';
}
function editOpenWithRule(index) {
const r = OpenWithRules[index];
if (!r) return;
document.getElementById('openwith-edit-id').value = String(index);
document.getElementById('openwith-input-name').value = r.name;
document.getElementById('openwith-input-exts').value = r.exts.join(', ');
document.getElementById('openwith-input-cmd').value = r.cmd;
document.getElementById('openwith-rule-form').style.display = 'block';
}
function hideOpenWithRuleForm() {
document.getElementById('openwith-rule-form').style.display = 'none';
}
function saveOpenWithRule() {
const editId = document.getElementById('openwith-edit-id').value;
const name = document.getElementById('openwith-input-name').value.trim();
const extsRaw = document.getElementById('openwith-input-exts').value.trim();
const cmd = document.getElementById('openwith-input-cmd').value.trim();
if (!name) {
showToast('Please enter an application / rule name', 'error');
return;
}
const exts = extsRaw.split(',').map(e => e.trim().replace(/^\./, '')).filter(Boolean);
if (exts.length === 0) exts.push('*');
const rule = {
id: 'rule-' + Date.now(),
name,
exts,
cmd,
icon: cmd.toLowerCase().includes('vlc') ? 'film' : (cmd.toLowerCase().includes('code') ? 'code' : 'external-link')
};
if (editId !== '') {
OpenWithRules[parseInt(editId)] = rule;
} else {
OpenWithRules.push(rule);
}
localStorage.setItem('cd_openwith_rules', JSON.stringify(OpenWithRules));
hideOpenWithRuleForm();
renderOpenWithRules();
showToast('Open-With rule saved', 'success');
}
function deleteOpenWithRule(index) {
OpenWithRules.splice(index, 1);
localStorage.setItem('cd_openwith_rules', JSON.stringify(OpenWithRules));
renderOpenWithRules();
showToast('Rule deleted', 'info');
}
async function executeOpenWith(filePath, command) {
try {
const res = await fetch('/api/system/open-with', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || ''}`,
},
body: JSON.stringify({ file_path: filePath, command: command || null }),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Failed to open file', 'error');
} else {
showToast(`Launched application`, 'success');
}
} catch (err) {
showToast('Failed to connect to system handler', 'error');
}
}
// ---------------- EXTERNAL DESKTOP PROGRAMS (STANDALONE ONLY) ----------------
function applyExternalProgramPreset(type, cmd) {
if (type === 'editor') {
const el = document.getElementById('setting-ext-editor');
if (el) el.value = cmd;
localStorage.setItem('cd_external_editor', cmd);
showToast(`External editor configured: ${cmd}`, 'success');
} else if (type === 'viewer') {
const el = document.getElementById('setting-ext-viewer');
if (el) el.value = cmd;
localStorage.setItem('cd_external_viewer', cmd);
showToast(`External viewer configured: ${cmd}`, 'success');
} else if (type === 'terminal') {
const el = document.getElementById('setting-ext-terminal');
if (el) el.value = cmd;
localStorage.setItem('cd_external_terminal', cmd);
showToast(`External terminal configured: ${cmd}`, 'success');
}
}
function toggleUseExtEditor(checked) {
localStorage.setItem('cd_use_ext_editor_f4', checked ? 'true' : 'false');
showToast(checked ? 'F4 (Edit) will now launch external editor' : 'F4 (Edit) will launch built-in EditorDog', 'info');
}
function toggleUseExtViewer(checked) {
localStorage.setItem('cd_use_ext_viewer_f3', checked ? 'true' : 'false');
showToast(checked ? 'F3 (View) will now launch external viewer' : 'F3 (View) will launch built-in Viewer', 'info');
}
async function testExternalProgram(type) {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || (pane?.entries ? pane.entries[pane.cursorIndex] : null);
const currentPath = item ? item.path : (pane?.path || '~');
if (type === 'editor') {
const cmd = document.getElementById('setting-ext-editor')?.value || localStorage.getItem('cd_external_editor') || 'code "%1"';
await executeOpenWith(currentPath, cmd);
} else if (type === 'viewer') {
const cmd = document.getElementById('setting-ext-viewer')?.value || localStorage.getItem('cd_external_viewer') || 'xdg-open "%1"';
await executeOpenWith(currentPath, cmd);
} else if (type === 'terminal') {
const cmd = document.getElementById('setting-ext-terminal')?.value || localStorage.getItem('cd_external_terminal') || 'x-terminal-emulator';
await executeOpenWith(currentPath, cmd);
}
}
async function openExternalTerminal(targetPath) {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || (pane?.entries ? pane.entries[pane.cursorIndex] : null);
const path = targetPath || (item ? item.path : (pane?.path || '~'));
const cmd = localStorage.getItem('cd_external_terminal') || App.config?.desktop?.external_terminal || 'x-terminal-emulator';
await executeOpenWith(path, cmd);
}
function renderDesktopAppsTab() {
const extEditorInput = document.getElementById('setting-ext-editor');
if (extEditorInput) {
extEditorInput.value = localStorage.getItem('cd_external_editor') || App.config?.desktop?.external_editor || '';
extEditorInput.oninput = (e) => {
localStorage.setItem('cd_external_editor', e.target.value.trim());
};
}
const extViewerInput = document.getElementById('setting-ext-viewer');
if (extViewerInput) {
extViewerInput.value = localStorage.getItem('cd_external_viewer') || App.config?.desktop?.external_viewer || '';
extViewerInput.oninput = (e) => {
localStorage.setItem('cd_external_viewer', e.target.value.trim());
};
}
const extTerminalInput = document.getElementById('setting-ext-terminal');
if (extTerminalInput) {
extTerminalInput.value = localStorage.getItem('cd_external_terminal') || App.config?.desktop?.external_terminal || '';
extTerminalInput.oninput = (e) => {
localStorage.setItem('cd_external_terminal', e.target.value.trim());
};
}
const useExtEditorCheckbox = document.getElementById('setting-use-ext-editor-f4');
if (useExtEditorCheckbox) {
const saved = localStorage.getItem('cd_use_ext_editor_f4');
useExtEditorCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.use_external_editor_f4 === true);
}
const useExtViewerCheckbox = document.getElementById('setting-use-ext-viewer-f3');
if (useExtViewerCheckbox) {
const saved = localStorage.getItem('cd_use_ext_viewer_f3');
useExtViewerCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.use_external_viewer_f3 === true);
}
const extMinTrayCheckbox = document.getElementById('setting-ext-minimize-tray');
if (extMinTrayCheckbox) {
const saved = localStorage.getItem('cd_minimize_tray');
extMinTrayCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.minimize_to_tray !== false);
}
const extStartMinCheckbox = document.getElementById('setting-ext-start-minimized');
if (extStartMinCheckbox) {
const saved = localStorage.getItem('cd_start_minimized');
extStartMinCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.start_minimized === true);
}
}
// ---------------- CONTEXT MENU CUSTOMIZER & SHELL ACTIONS ----------------
let ContextItemVisibility = JSON.parse(localStorage.getItem('cd_context_visibility') || '{"view":true,"edit":true,"openwith":true,"download":true,"share":true,"diff":true}');
let CustomShellActions = JSON.parse(localStorage.getItem('cd_custom_shell_actions') || 'null');
if (!CustomShellActions) {
CustomShellActions = [
{ id: 'git-status', label: 'Git Status Here', icon: 'git-branch', cmd: 'git -C {dir} status' },
{ id: 'zip-sel', label: 'Compress Selection (7z)', icon: 'archive', cmd: '7z a -tzip "{dir}/archive.zip" {selection}' },
];
}
function toggleContextItemVisibility(key, visible) {
ContextItemVisibility[key] = visible;
localStorage.setItem('cd_context_visibility', JSON.stringify(ContextItemVisibility));
showToast(`Context item "${key}" ${visible ? 'enabled' : 'hidden'}`, 'info');
}
function renderCustomActionsList() {
const list = document.getElementById('custom-actions-list');
if (!list) return;
if (CustomShellActions.length === 0) {
list.innerHTML = '<div style="color: var(--text-muted); font-size: 12px; padding: 12px 0;">No custom actions configured yet. Click "New Shell Action" above.</div>';
return;
}
list.innerHTML = CustomShellActions.map((a, idx) => `
<div class="rule-config-row">
<i data-lucide="${a.icon || 'terminal'}" style="width: 16px; color: var(--accent);"></i>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 12px;">${escapeHtml(a.label)}</div>
<div style="font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); margin-top: 2px;">${escapeHtml(a.cmd)}</div>
</div>
<button class="btn btn-icon btn-sm" onclick="editCustomAction(${idx})" title="Edit"><i data-lucide="edit-3" style="width: 13px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="deleteCustomAction(${idx})" title="Delete"><i data-lucide="trash-2" style="width: 13px; color: var(--danger);"></i></button>
</div>
`).join('');
if (window.lucide) lucide.createIcons();
}
function showAddCustomActionPrompt() {
document.getElementById('custom-action-edit-id').value = '';
document.getElementById('custom-action-input-label').value = '';
document.getElementById('custom-action-input-icon').value = 'terminal';
document.getElementById('custom-action-input-cmd').value = '';
document.getElementById('custom-action-form').style.display = 'block';
}
function editCustomAction(index) {
const a = CustomShellActions[index];
if (!a) return;
document.getElementById('custom-action-edit-id').value = String(index);
document.getElementById('custom-action-input-label').value = a.label;
document.getElementById('custom-action-input-icon').value = a.icon || 'terminal';
document.getElementById('custom-action-input-cmd').value = a.cmd;
document.getElementById('custom-action-form').style.display = 'block';
}
function hideCustomActionForm() {
document.getElementById('custom-action-form').style.display = 'none';
}
function saveCustomShellAction() {
const editId = document.getElementById('custom-action-edit-id').value;
const label = document.getElementById('custom-action-input-label').value.trim();
const icon = document.getElementById('custom-action-input-icon').value.trim() || 'terminal';
const cmd = document.getElementById('custom-action-input-cmd').value.trim();
if (!label || !cmd) {
showToast('Please enter both label and command', 'error');
return;
}
const action = {
id: 'action-' + Date.now(),
label,
icon,
cmd
};
if (editId !== '') {
CustomShellActions[parseInt(editId)] = action;
} else {
CustomShellActions.push(action);
}
localStorage.setItem('cd_custom_shell_actions', JSON.stringify(CustomShellActions));
hideCustomActionForm();
renderCustomActionsList();
showToast('Custom action saved', 'success');
}
function deleteCustomAction(index) {
CustomShellActions.splice(index, 1);
localStorage.setItem('cd_custom_shell_actions', JSON.stringify(CustomShellActions));
renderCustomActionsList();
showToast('Action deleted', 'info');
}
async function executeCustomAction(cmd, targetPath) {
const pane = App.panes[App.activePaneIndex];
const selection = pane && pane.selected && pane.selected.size > 0 ? Array.from(pane.selected) : null;
const targetPane = App.panes[App.activePaneIndex === 0 ? 1 : 0];
const targetPanePath = targetPane ? targetPane.path : null;
try {
const res = await fetch('/api/system/run-custom-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || ''}`,
},
body: JSON.stringify({
command: cmd,
target_path: targetPath,
selection: selection,
target_pane_path: targetPanePath,
}),
});
const data = await res.json();
if (!res.ok) {
showToast(data.error || 'Execution failed', 'error');
} else {
showToast(`Action finished: ${data.executed}`, 'success');
refreshAllPanes();
}
} catch (err) {
showToast('Failed to run action on host', 'error');
}
}
// ---------------- SETTINGS & CONF.D INSPECTOR ----------------
function switchSettingsTab(tabId) {
const modal = document.getElementById('settings-modal');
if (!modal) return;
// Backward compatibility alias for merged tabs
if (tabId === 'tab-columns') {
tabId = 'tab-general';
}
if (tabId === 'tab-desktop') {
tabId = 'tab-desktop-apps';
}
modal.querySelectorAll('.settings-tab-btn, .settings-nav-btn').forEach(btn => btn.classList.remove('active'));
modal.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
let activeBtn = null;
if (window.event && window.event.currentTarget && (window.event.currentTarget.classList.contains('settings-tab-btn') || window.event.currentTarget.classList.contains('settings-nav-btn'))) {
activeBtn = window.event.currentTarget;
} else {
activeBtn = modal.querySelector(`[onclick*="${tabId}"]`);
}
if (activeBtn) {
activeBtn.classList.add('active');
}
document.getElementById(tabId)?.classList.add('active');
if (tabId === 'tab-general') updateColumnCheckboxes();
if (tabId === 'tab-bookmarks') loadBookmarksList();
if (tabId === 'tab-desktop-apps') renderDesktopAppsTab();
if (tabId === 'tab-openwith') renderOpenWithRules();
if (tabId === 'tab-context') renderCustomActionsList();
if (tabId === 'tab-icons') renderIconSettingsTab();
if (tabId === 'tab-templates') renderFileTemplatesList();
if (tabId === 'tab-tools') renderToolsSettingsTab();
if (tabId === 'tab-plugins') loadInstalledChewToys();
if (tabId === 'tab-about') updateAboutModalContent();
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons();
}
}
function switchAdminTab(tabId) {
const modal = document.getElementById('admin-panel-modal');
if (!modal) return;
modal.querySelectorAll('.settings-tab-btn, .settings-nav-btn').forEach(btn => btn.classList.remove('active'));
modal.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
let activeBtn = null;
if (window.event && window.event.currentTarget && (window.event.currentTarget.classList.contains('settings-tab-btn') || window.event.currentTarget.classList.contains('settings-nav-btn'))) {
activeBtn = window.event.currentTarget;
} else {
activeBtn = modal.querySelector(`[onclick*="${tabId}"]`);
}
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
document.getElementById(tabId)?.classList.add('active');
if (tabId === 'admin-tab-users') loadUsersTable();
if (tabId === 'admin-tab-tokens') loadAdminApiTokens();
if (tabId === 'admin-tab-storage') loadAdminGlobalMounts();
if (tabId === 'admin-tab-config') loadMasterConfigEditor();
if (tabId === 'admin-tab-security') loadAdminSecuritySettings();
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons();
}
}
async function loadUsersTable() {
const container = document.getElementById('users-cards-container') || document.getElementById('users-table-body');
if (!container) return;
container.innerHTML = '<div style="text-align:center; padding: 24px; color: var(--text-dim); display:flex; flex-direction:column; align-items:center; gap:8px;"><i data-lucide="loader-2" class="spin" style="width: 20px; height: 20px;"></i><div>Loading user database & RBAC permissions...</div></div>';
if (window.lucide) lucide.createIcons();
try {
const [usersRes, rootsRes] = await Promise.all([
fetch('/api/auth/users', { headers: { 'Authorization': `Bearer ${App.token}` } }),
fetch('/api/storage/roots', { headers: { 'Authorization': `Bearer ${App.token}` } })
]);
if (usersRes.ok) {
const users = await usersRes.json();
const storageRoots = rootsRes.ok ? await rootsRes.json() : [];
if (!users || users.length === 0) {
container.innerHTML = '<div class="settings-empty-state"><i data-lucide="users"></i><p>No user accounts found.</p></div>';
if (window.lucide) lucide.createIcons();
return;
}
container.innerHTML = users.map(u => {
let allowed = [];
try {
allowed = typeof u.allowed_services === 'string' ? JSON.parse(u.allowed_services) : (u.allowed_services || ['*']);
} catch (_) {
allowed = ['*'];
}
const hasAll = allowed.includes('*');
let allowedRoots = [];
try {
allowedRoots = typeof u.allowed_roots === 'string' ? JSON.parse(u.allowed_roots) : (u.allowed_roots || ['*']);
} catch (_) {
allowedRoots = ['*'];
}
const hasAllRoots = allowedRoots.includes('*');
let allowedPlugins = ['*'];
try {
allowedPlugins = typeof u.allowed_plugins === 'string' ? JSON.parse(u.allowed_plugins) : (u.allowed_plugins || ['*']);
} catch (_) {
allowedPlugins = ['*'];
}
let blockedPlugins = [];
try {
blockedPlugins = typeof u.blocked_plugins === 'string' ? JSON.parse(u.blocked_plugins) : (u.blocked_plugins || []);
} catch (_) {
blockedPlugins = [];
}
const canInstallPlugins = !!u.can_install_plugins;
const services = ['local', 'smb', 'nfs', 's3', 'sftp', 'webdav', 'terminal', 'syncthing', 'converters', 'upload', 'download'];
const serviceLabels = {
'local': 'Local',
'smb': 'SMB/CIFS',
'nfs': 'NFS',
's3': 'S3',
'sftp': 'SFTP',
'webdav': 'WebDAV',
'terminal': 'Terminal',
'syncthing': 'Syncthing',
'converters': 'ConvertX',
'upload': 'Upload',
'download': 'Download'
};
const safeUname = escapeHtml(u.username);
const isDisabled = !!u.is_disabled;
return `
<div class="admin-user-card" id="user-card-${safeUname}">
<div class="admin-user-card-header">
<div class="admin-user-card-title-group">
<div class="profile-avatar-thumb" style="width: 32px; height: 32px; font-size: 14px; border-radius: 50%;">
${u.avatar_url && (u.avatar_url.startsWith('data:image') || u.avatar_url.startsWith('http') || u.avatar_url.startsWith('/')) ? `<img src="${escapeHtml(u.avatar_url)}" alt="Avatar" style="width:100%; height:100%; border-radius:50%; object-fit:cover;">` : escapeHtml(u.avatar_url || '👤')}
</div>
<div>
<div class="admin-user-name-row">
<span class="admin-user-name">${safeUname}</span>
${u.is_pam ? '<span class="badge" style="font-size:10px; background:rgba(56,189,248,0.15); color:var(--info); border:1px solid rgba(56,189,248,0.3);">PAM / Linux</span>' : '<span class="badge" style="font-size:10px; background:rgba(245,158,11,0.15); color:var(--accent); border:1px solid rgba(245,158,11,0.3);">Database</span>'}
</div>
<div class="admin-user-sub">${escapeHtml(u.email || (u.nickname ? `@${u.nickname}` : 'System user account'))}</div>
</div>
</div>
<div class="admin-user-header-actions">
<label class="admin-user-status-chip ${isDisabled ? 'disabled' : 'active'}" id="user-status-chip-${safeUname}" style="cursor: pointer;" title="Toggle Account Active/Disabled">
<input type="checkbox" id="user-disabled-${safeUname}" ${isDisabled ? 'checked' : ''} onchange="handleUserDisabledToggle('${safeUname}', this.checked)" style="display:none;">
<i data-lucide="${isDisabled ? 'shield-ban' : 'shield-check'}" style="width: 13px; height: 13px;"></i>
<span id="user-disabled-badge-${safeUname}">${isDisabled ? 'Disabled' : 'Active'}</span>
</label>
<button type="button" class="btn btn-accent btn-sm" style="height: 28px; padding: 0 10px; font-size: 11px; display: inline-flex; align-items: center; gap: 4px;" onclick="saveUserRbac('${safeUname}')" title="Save RBAC & Roots">
<i data-lucide="save" style="width: 12px; height: 12px;"></i> Save
</button>
<button type="button" class="btn btn-danger btn-sm" style="height: 28px; padding: 0 8px; font-size: 11px; display: inline-flex; align-items: center;" onclick="deleteUserAccount('${safeUname}')" title="Delete User Account">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>
</button>
</div>
</div>
<div class="admin-user-card-body">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
<div>
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px;">Account Role</label>
<select id="user-role-${safeUname}" class="pane-quick-filter" style="width: 100%; height: 28px; padding: 4px 8px; font-size: 11px;">
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Administrator (Full Access)</option>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>Standard User (Read/Write)</option>
<option value="readonly" ${u.role === 'readonly' ? 'selected' : ''}>Read-Only User</option>
</select>
</div>
<div>
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px;">Home Directory Root</label>
<input type="text" id="user-home-${safeUname}" class="pane-quick-filter" value="${escapeHtml(u.home_dir || '/')}" style="width: 100%; height: 28px; padding: 4px 8px; font-size: 11px;" placeholder="/home/username">
</div>
</div>
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
<label style="font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px;">Allowed Storage Roots</label>
<span style="font-size: 10px; color: var(--text-dim);">Accessible storage scopes</span>
</div>
<div class="admin-roots-selector-box">
<label class="admin-chip-checkbox ${hasAllRoots ? 'checked' : ''}" id="chip-root-all-${safeUname}">
<input type="checkbox" class="user-root-cb-${safeUname}" value="*" ${hasAllRoots ? 'checked' : ''} onchange="toggleAllRootsCheck('${safeUname}', this.checked)">
<span>⭐ All Storage Roots (*)</span>
</label>
${storageRoots.filter(r => r.id !== 'system-root').map(r => {
const isChecked = hasAllRoots || allowedRoots.includes(r.id) || allowedRoots.includes(r.path);
return `
<label class="admin-chip-checkbox ${isChecked ? 'checked' : ''}" id="chip-root-${safeUname}-${escapeHtml(r.id)}">
<input type="checkbox" class="user-root-cb-${safeUname}" value="${escapeHtml(r.id)}" ${isChecked ? 'checked' : ''} onchange="this.parentElement.classList.toggle('checked', this.checked)">
<span>${escapeHtml(r.name)} ${r.read_only ? '<small style="opacity:0.7;">(RO)</small>' : ''}</span>
</label>
`;
}).join('')}
</div>
</div>
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
<label style="font-size: 10px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Allowed Protocol Services & Features</label>
<span style="font-size: 10px; color: var(--text-dim);">Granular RBAC</span>
</div>
<div class="admin-services-grid">
${services.map(svc => {
const isChecked = hasAll || allowed.includes(svc);
return `
<label class="admin-service-chip" style="${isChecked ? 'border-color: rgba(245, 158, 11, 0.35); color: var(--text-main);' : ''}">
<input type="checkbox" id="svc-${safeUname}-${svc}" ${isChecked ? 'checked' : ''} onchange="this.parentElement.style.borderColor = this.checked ? 'rgba(245, 158, 11, 0.35)' : 'var(--border)'; this.parentElement.style.color = this.checked ? 'var(--text-main)' : 'var(--text-muted)';">
<span>${serviceLabels[svc]}</span>
</label>
`;
}).join('')}
</div>
</div>
<div style="margin-top: 6px; border-top: 1px solid rgba(255,255,255,0.06); padding-top: 10px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
<label style="font-size: 10px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px;">ChewToys & Plugins Governance (.grr)</label>
<span style="font-size: 10px; color: var(--text-dim);">Whitelist / Blacklist</span>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label class="admin-service-chip" style="width: fit-content; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; ${canInstallPlugins ? 'border-color: rgba(245, 158, 11, 0.35); color: var(--text-main);' : ''}">
<input type="checkbox" id="user-plugin-install-${safeUname}" ${canInstallPlugins ? 'checked' : ''} onchange="this.parentElement.style.borderColor = this.checked ? 'rgba(245, 158, 11, 0.35)' : 'var(--border)'; this.parentElement.style.color = this.checked ? 'var(--text-main)' : 'var(--text-muted)';">
<span>Allow installing custom ChewToys (.grr)</span>
</label>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px;">
<div>
<label style="display: block; font-size: 10px; font-weight: 600; color: var(--text-dim); margin-bottom: 3px;">Allowed ChewToys (* for all)</label>
<input type="text" id="user-plugin-allowed-${safeUname}" class="pane-quick-filter" value="${escapeHtml(Array.isArray(allowedPlugins) ? allowedPlugins.join(', ') : allowedPlugins)}" style="width: 100%; height: 26px; padding: 2px 8px; font-size: 11px;" placeholder="* or id1, id2">
</div>
<div>
<label style="display: block; font-size: 10px; font-weight: 600; color: var(--text-dim); margin-bottom: 3px;">Blocked ChewToys (Blacklist)</label>
<input type="text" id="user-plugin-blocked-${safeUname}" class="pane-quick-filter" value="${escapeHtml(Array.isArray(blockedPlugins) ? blockedPlugins.join(', ') : blockedPlugins)}" style="width: 100%; height: 26px; padding: 2px 8px; font-size: 11px;" placeholder="None or id1, id2">
</div>
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
} catch (e) {
console.error('loadUsersTable error:', e);
}
}
function handleUserDisabledToggle(username, isChecked) {
const badge = document.getElementById(`user-disabled-badge-${username}`);
const chip = document.getElementById(`user-status-chip-${username}`);
if (chip) {
chip.classList.toggle('disabled', isChecked);
chip.classList.toggle('active', !isChecked);
const icon = chip.querySelector('i');
if (icon) {
icon.setAttribute('data-lucide', isChecked ? 'shield-ban' : 'shield-check');
if (window.lucide) lucide.createIcons();
}
}
if (badge) {
badge.textContent = isChecked ? 'Disabled' : 'Active';
}
}
function toggleAllRootsCheck(username, checked) {
const chipAll = document.getElementById(`chip-root-all-${username}`);
if (chipAll) chipAll.classList.toggle('checked', checked);
const rootCheckboxes = document.querySelectorAll(`.user-root-cb-${username}`);
rootCheckboxes.forEach(cb => {
if (cb.value !== '*') {
cb.checked = checked;
const parent = cb.closest('.admin-chip-checkbox');
if (parent) parent.classList.toggle('checked', checked);
}
});
}
async function saveUserRbac(username) {
const roleSelect = document.getElementById(`user-role-${username}`);
const homeInput = document.getElementById(`user-home-${username}`);
const disabledCb = document.getElementById(`user-disabled-${username}`);
const role = roleSelect?.value || 'user';
const home_dir = homeInput?.value || '/';
const is_disabled = disabledCb ? disabledCb.checked : false;
const services = ['local', 'smb', 'nfs', 's3', 'sftp', 'webdav', 'terminal', 'syncthing', 'converters', 'upload', 'download'];
const allowed_services = [];
for (const svc of services) {
const cb = document.getElementById(`svc-${username}-${svc}`);
if (cb && cb.checked) {
allowed_services.push(svc);
}
}
const allowed_roots = [];
const rootCheckboxes = document.querySelectorAll(`.user-root-cb-${username}:checked`);
rootCheckboxes.forEach(cb => {
allowed_roots.push(cb.value);
});
const canInstallCb = document.getElementById(`user-plugin-install-${username}`);
const allowedInput = document.getElementById(`user-plugin-allowed-${username}`);
const blockedInput = document.getElementById(`user-plugin-blocked-${username}`);
const can_install_plugins = canInstallCb ? canInstallCb.checked : false;
const allowed_plugins = allowedInput ? allowedInput.value.split(',').map(s => s.trim()).filter(Boolean) : ['*'];
const blocked_plugins = blockedInput ? blockedInput.value.split(',').map(s => s.trim()).filter(Boolean) : [];
try {
const resp = await fetch(`/api/auth/users/${encodeURIComponent(username)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
role: role,
allowed_services: allowed_services,
allowed_roots: allowed_roots.length > 0 ? allowed_roots : ['*'],
home_dir: home_dir,
can_install_plugins: can_install_plugins,
allowed_plugins: allowed_plugins.length > 0 ? allowed_plugins : ['*'],
blocked_plugins: blocked_plugins,
is_disabled: is_disabled
})
});
if (resp.ok) {
showToast(`RBAC, storage roots & plugins permissions for '${username}' saved!`, 'success');
loadUsersTable();
} else {
showToast(`Failed to save RBAC: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Save error: ${e}`, 'error');
}
}
async function deleteUserAccount(username) {
const confirmed = await showConfirmDialog({
title: 'Delete User Account',
subtitle: 'Admin Control Panel',
message: `Are you sure you want to delete user account '${username}'?`,
icon: 'user-x',
type: 'danger',
confirmText: 'Delete User',
cancelText: 'Cancel',
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/auth/users/${encodeURIComponent(username)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast(`User '${username}' deleted`, 'success');
loadUsersTable();
} else {
showToast('Failed to delete user: ' + await resp.text(), 'error');
}
} catch (e) {
showToast('Delete error: ' + e, 'error');
}
}
async function loadMasterConfigEditor() {
const textarea = document.getElementById('admin-config-editor-textarea');
const pathBadge = document.getElementById('admin-config-path-badge');
const statusEl = document.getElementById('admin-config-status');
if (textarea) textarea.value = '# Loading configuration file...';
if (statusEl) statusEl.style.display = 'none';
try {
const resp = await fetch('/api/system/config-file', {
headers: { 'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}` }
});
if (resp.ok) {
const data = await resp.json();
if (textarea) textarea.value = data.content;
if (pathBadge) pathBadge.textContent = data.path;
if (!data.is_writable && statusEl) {
statusEl.style.display = 'block';
statusEl.style.color = 'var(--accent)';
statusEl.textContent = '⚠️ Config file directory is read-only. Changes cannot be saved directly.';
}
} else {
const err = await resp.text();
if (textarea) textarea.value = `# Error loading config: ${err}`;
}
} catch (e) {
if (textarea) textarea.value = `# Network error fetching config: ${e}`;
}
}
async function saveMasterConfigFile() {
const textarea = document.getElementById('admin-config-editor-textarea');
const statusEl = document.getElementById('admin-config-status');
const saveBtn = document.getElementById('btn-save-master-config');
if (!textarea) return;
const content = textarea.value;
if (saveBtn) saveBtn.disabled = true;
if (statusEl) {
statusEl.style.display = 'block';
statusEl.style.color = 'var(--text-muted)';
statusEl.textContent = 'Validating and saving configuration...';
}
try {
const resp = await fetch('/api/system/config-file', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}`
},
body: JSON.stringify({ content })
});
if (resp.ok) {
const data = await resp.json();
showToast('Master config.toml saved successfully!', 'success');
if (statusEl) {
statusEl.style.color = 'var(--success, #10b981)';
statusEl.textContent = `✓ ${data.message}`;
}
} else {
const err = await resp.text();
showToast('Failed to save config.toml', 'error');
if (statusEl) {
statusEl.style.color = 'var(--danger, #ef4444)';
statusEl.textContent = `✗ ${err}`;
}
}
} catch (e) {
showToast(`Error saving config: ${e}`, 'error');
if (statusEl) {
statusEl.style.color = 'var(--danger, #ef4444)';
statusEl.textContent = `✗ Error: ${e}`;
}
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
async function triggerServerReload() {
showToast('Reloading configuration into memory...', 'info');
try {
const resp = await fetch('/api/system/reload-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}`
}
});
if (resp.ok) {
await loadConfig();
showToast('✓ Configuration successfully reloaded into running server memory!', 'success');
} else {
const err = await resp.text();
showToast(`✗ Failed to reload config: ${err}`, 'error');
}
} catch (e) {
showToast(`Error reloading configuration: ${e}`, 'error');
}
}
async function triggerServerRestart() {
const confirmed = await showConfirmDialog({
title: 'Restart Server Process',
subtitle: 'Process Lifecycle & Reconnect',
message: 'Are you sure you want to restart the Brum server process? Active connections will momentarily reconnect.',
icon: 'refresh-cw',
type: 'warning',
confirmText: 'Restart Server'
});
if (!confirmed) return;
showToast('Restarting Brum server...', 'info');
try {
await fetch('/api/system/restart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}`
}
});
} catch (e) {}
setTimeout(() => {
location.reload();
}, 1500);
}
function showAddUserPrompt() {
document.getElementById('new-user-username').value = '';
document.getElementById('new-user-password').value = '';
document.getElementById('new-user-password').type = 'password';
document.getElementById('new-user-role').value = 'user';
document.getElementById('new-user-home').value = '/';
const err = document.getElementById('add-user-error');
if (err) {
err.style.display = 'none';
err.textContent = '';
}
showModal('add-user-modal');
document.getElementById('new-user-username')?.focus();
}
async function submitAddUser() {
const username = document.getElementById('new-user-username').value.trim();
const password = document.getElementById('new-user-password').value;
const role = document.getElementById('new-user-role').value;
const home_dir = document.getElementById('new-user-home').value.trim() || '/';
const err = document.getElementById('add-user-error');
if (!username || !password) {
if (err) {
err.style.display = 'block';
err.textContent = 'Username and password are required.';
}
return;
}
try {
const resp = await fetch('/api/auth/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ username, password, role, home_dir })
});
if (resp.ok) {
closeModal('add-user-modal');
loadUsersTable();
} else {
const msg = await resp.text();
if (err) {
err.style.display = 'block';
err.textContent = `Failed to create user: ${msg}`;
}
}
} catch (e) {
if (err) {
err.style.display = 'block';
err.textContent = `Error: ${e}`;
}
}
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
if (input) {
input.type = input.type === 'password' ? 'text' : 'password';
}
}
const togglePassVisibility = togglePasswordVisibility;
// ---------------- API & FLEET SERVICE TOKENS ENGINE ----------------
async function loadAdminApiTokens() {
const container = document.getElementById('admin-api-tokens-list');
if (!container) return;
container.innerHTML = '<div style="text-align:center; padding: 24px; color: var(--text-dim); display:flex; flex-direction:column; align-items:center; gap:8px;"><i data-lucide="loader-2" class="spin" style="width: 20px; height: 20px;"></i><div>Loading API tokens...</div></div>';
if (window.lucide) lucide.createIcons({ root: container });
try {
const resp = await fetch('/api/auth/tokens', {
headers: { 'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}` }
});
if (!resp.ok) {
container.innerHTML = `<div class="settings-empty-state"><i data-lucide="alert-circle" style="color:#ef4444;"></i><p>Failed to load API tokens (HTTP ${resp.status})</p></div>`;
if (window.lucide) lucide.createIcons({ root: container });
return;
}
const tokens = await resp.json();
if (!tokens || tokens.length === 0) {
container.innerHTML = `
<div class="settings-empty-state" style="padding: 24px; text-align: center;">
<i data-lucide="key-round" style="width: 28px; height: 28px; color: var(--text-dim); margin-bottom: 8px;"></i>
<p style="margin: 0; font-size: 12.5px; color: var(--text-main);">No active API or fleet service tokens.</p>
<p style="margin: 4px 0 12px 0; font-size: 11px; color: var(--text-dim);">Generate a token to authenticate remote Fleet Control nodes, headless daemons, or automation scripts.</p>
<button type="button" class="btn btn-accent btn-sm" onclick="openCreateApiTokenModal()"><i data-lucide="plus" style="width: 12px; height: 12px;"></i> Generate First Token</button>
</div>
`;
if (window.lucide) lucide.createIcons({ root: container });
return;
}
let html = '';
const now = Math.floor(Date.now() / 1000);
tokens.forEach(tok => {
const isExpired = tok.expires_at && tok.expires_at > 0 && tok.expires_at < now;
let expiryLabel = 'Never (Permanent)';
let expiryColor = '#10b981';
if (tok.expires_at && tok.expires_at > 0) {
const expDate = new Date(tok.expires_at * 1000).toLocaleDateString();
if (isExpired) {
expiryLabel = `Expired on ${expDate}`;
expiryColor = '#ef4444';
} else {
expiryLabel = `Expires ${expDate}`;
expiryColor = 'var(--text-dim)';
}
}
const createdDate = new Date(tok.created_at * 1000).toLocaleDateString();
const lastUsedLabel = tok.last_used_at ? new Date(tok.last_used_at * 1000).toLocaleString() : 'Never';
let roleBadgeColor = 'rgba(245, 158, 11, 0.15)';
let roleTextColor = 'var(--accent)';
if (tok.role === 'admin') {
roleBadgeColor = 'rgba(239, 68, 68, 0.15)';
roleTextColor = '#ef4444';
} else if (tok.role === 'readonly') {
roleBadgeColor = 'rgba(100, 116, 139, 0.2)';
roleTextColor = 'var(--text-dim)';
}
html += `
<div class="settings-group-card" style="padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card, rgba(0,0,0,0.15));">
<div style="display: flex; flex-direction: column; gap: 4px; min-width: 0;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 700; font-size: 13px; color: var(--text-main);">${escapeHtml(tok.name)}</span>
<span class="fleet-tag-pill" style="font-size: 9.5px; padding: 1px 6px; background: ${roleBadgeColor}; color: ${roleTextColor}; text-transform: uppercase;">${escapeHtml(tok.role)}</span>
<span style="font-size: 10.5px; color: ${expiryColor}; font-weight: 600;">${expiryLabel}</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: var(--text-dim); flex-wrap: wrap;">
<span style="font-family: var(--font-mono); color: var(--text-main);"><i data-lucide="key" style="width: 11px; height: 11px; vertical-align: middle;"></i> ${escapeHtml(tok.token_prefix)}</span>
<span>Owner: <strong style="color: var(--text-main);">${escapeHtml(tok.username)}</strong></span>
<span>Created: ${createdDate}</span>
<span>Last Used: ${lastUsedLabel}</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<button type="button" class="btn btn-xs btn-outline" onclick="revokeApiToken('${escapeHtml(tok.id)}', '${escapeHtml(tok.name)}')" title="Revoke Token" style="height: 26px; padding: 0 8px; color: #ef4444; display: inline-flex; align-items: center; gap: 4px;">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i> Revoke
</button>
</div>
</div>
`;
});
container.innerHTML = html;
if (window.lucide) lucide.createIcons({ root: container });
} catch (err) {
container.innerHTML = `<div class="settings-empty-state"><i data-lucide="alert-circle" style="color:#ef4444;"></i><p>Network error: ${escapeHtml(err.message)}</p></div>`;
if (window.lucide) lucide.createIcons({ root: container });
}
}
function openCreateApiTokenModal() {
const formView = document.getElementById('token-generator-form-view');
const revealView = document.getElementById('token-generator-reveal-view');
if (formView) formView.style.display = 'flex';
if (revealView) revealView.style.display = 'none';
const nameInput = document.getElementById('token-input-name');
if (nameInput) {
nameInput.value = '';
setTimeout(() => nameInput.focus(), 80);
}
const roleInput = document.getElementById('token-input-role');
if (roleInput) roleInput.value = 'user';
const expiryInput = document.getElementById('token-input-expiry');
if (expiryInput) expiryInput.value = '0';
const rootsInput = document.getElementById('token-input-roots');
if (rootsInput) rootsInput.value = '';
const fleetBtn = document.getElementById('btn-use-in-fleet');
const fleetModal = document.getElementById('fleet-manager-modal');
if (fleetBtn) {
fleetBtn.style.display = (fleetModal && fleetModal.classList.contains('active')) ? 'inline-flex' : 'none';
}
showModal('create-api-token-modal');
}
async function submitCreateApiToken() {
const name = (document.getElementById('token-input-name')?.value || '').trim();
const role = document.getElementById('token-input-role')?.value || 'user';
const expiryDays = parseInt(document.getElementById('token-input-expiry')?.value || '0', 10);
const rootsRaw = (document.getElementById('token-input-roots')?.value || '').trim();
const btn = document.getElementById('btn-generate-token-submit');
if (!name) {
showToast('Please enter a friendly name for the token', 'error');
document.getElementById('token-input-name')?.focus();
return;
}
const allowedRoots = rootsRaw
? rootsRaw.split(',').map(r => r.trim()).filter(Boolean)
: null;
if (btn) btn.disabled = true;
try {
const resp = await fetch('/api/auth/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}`
},
body: JSON.stringify({
name,
role,
expires_in_days: expiryDays > 0 ? expiryDays : null,
allowed_roots: allowedRoots
})
});
if (!resp.ok) {
const errText = await resp.text();
showToast(`Failed to generate token: ${errText}`, 'error');
return;
}
const data = await resp.json();
const rawSecret = data.token;
// Switch to Reveal View
const formView = document.getElementById('token-generator-form-view');
const revealView = document.getElementById('token-generator-reveal-view');
const secretInput = document.getElementById('token-reveal-secret');
const curlExample = document.getElementById('token-curl-example');
if (secretInput) secretInput.value = rawSecret;
if (curlExample) {
curlExample.textContent = `curl -H "Authorization: Bearer ${rawSecret}" ${window.location.origin}/api/health`;
}
if (formView) formView.style.display = 'none';
if (revealView) revealView.style.display = 'flex';
if (window.lucide) lucide.createIcons({ root: revealView });
loadAdminApiTokens();
showToast('API token generated successfully!', 'success');
} catch (err) {
showToast(`Error generating token: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function copyGeneratedApiToken() {
const secretInput = document.getElementById('token-reveal-secret');
if (!secretInput || !secretInput.value) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(secretInput.value);
} else {
secretInput.select();
document.execCommand('copy');
}
showToast('Token copied to clipboard!', 'success');
}
function copyAndUseTokenInFleetForm() {
const secretInput = document.getElementById('token-reveal-secret');
if (!secretInput || !secretInput.value) return;
copyGeneratedApiToken();
const fleetTokenInput = document.getElementById('fleet-input-token');
if (fleetTokenInput) {
fleetTokenInput.value = secretInput.value;
}
closeModal('create-api-token-modal');
showToast('Token inserted into Fleet profile form!', 'success');
}
async function revokeApiToken(tokenId, tokenName) {
const confirmed = await showConfirmDialog({
title: 'Revoke API Token',
subtitle: `Revoke token "${tokenName}"`,
message: `Are you sure you want to revoke token "${tokenName}"? Any remote node, daemon, or script using this token will be immediately disconnected.`,
icon: 'trash-2',
type: 'danger',
confirmText: 'Revoke Token'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/auth/tokens/${encodeURIComponent(tokenId)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${App.token || localStorage.getItem('cd_token') || ''}`
}
});
if (resp.ok) {
showToast(`Token "${tokenName}" revoked`, 'info');
loadAdminApiTokens();
} else {
const err = await resp.text();
showToast(`Failed to revoke token: ${err}`, 'error');
}
} catch (err) {
showToast(`Error revoking token: ${err.message}`, 'error');
}
}
// ---------------- TRANSFERS & DRAG-AND-DROP ----------------
let pendingInterpaneTransfer = null;
async function handlePaneDrop(e, targetPaneIndex, subfolderPath) {
e.preventDefault();
// Pane Badge Drag & Drop Reordering
if (window._draggingPaneIndex !== null && window._draggingPaneIndex !== undefined) {
const srcIdx = window._draggingPaneIndex;
window._draggingPaneIndex = null;
document.querySelectorAll('.pane-reorder-target').forEach(p => p.classList.remove('pane-reorder-target'));
document.getElementById('panes-grid')?.classList.remove('pane-reorder-active');
document.querySelectorAll('.dragging-pane-badge').forEach(b => b.classList.remove('dragging-pane-badge'));
if (srcIdx !== targetPaneIndex) {
swapPanes(srcIdx, targetPaneIndex);
}
return;
}
const targetPane = App.panes[targetPaneIndex];
const paneEl = document.getElementById(`pane-${targetPaneIndex}`);
if (paneEl) paneEl.classList.remove('drag-over');
const destPath = subfolderPath || targetPane.path;
// OS Desktop Drag & Drop Upload
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
if (typeof destPath === 'string' && destPath.startsWith('client://')) {
const fileCount = e.dataTransfer.files.length;
let successCount = 0;
for (let f of e.dataTransfer.files) {
try {
const itemUri = destPath.endsWith('/') ? `${destPath}${f.name}` : `${destPath}/${f.name}`;
const fileHandle = await resolveClientFileHandle(itemUri, true);
const writable = await fileHandle.createWritable();
await writable.write(f);
await writable.close();
successCount++;
} catch (err) {
console.error('Error dropping file to client local:', err);
}
}
showToast(`Saved ${successCount} file(s) to client local folder`, 'success');
refreshPane(targetPaneIndex);
return;
}
const fileArr = Array.from(e.dataTransfer.files);
const collisions = detectCollisions(fileArr, targetPane.entries || [], destPath);
if (collisions.length > 0) {
pendingConflictBatch = {
action: 'upload',
files: fileArr,
destination: destPath,
refreshTargetPaneIdx: targetPaneIndex,
sourcePaneIdx: targetPaneIndex,
collisions,
currentIndex: 0,
decisions: {},
uploadType: 'dnd'
};
renderCurrentConflictModal();
showModal('conflict-resolution-modal');
return;
}
executeUploadWithDecisions({
files: fileArr,
destination: destPath,
refreshTargetPaneIdx: targetPaneIndex,
decisions: {}
});
return;
}
// Inter-Pane Transfer
let payloadObj = null;
const rawData = e.dataTransfer ? (e.dataTransfer.getData('application/json') || e.dataTransfer.getData('text/plain')) : '';
if (rawData) {
try {
payloadObj = JSON.parse(rawData);
} catch (_) {}
}
if (!payloadObj && window._activeDraggedPayload) {
payloadObj = window._activeDraggedPayload;
}
if (payloadObj) {
try {
const { sourcePane, paths } = payloadObj;
if (!paths || paths.length === 0) return;
const cleanDest = (destPath || '').replace(/[\\/]+$/, '').toLowerCase();
for (const p of paths) {
const cleanP = (p || '').replace(/[\\/]+$/, '').toLowerCase();
if (cleanDest === cleanP) {
showToast('Source and destination are identical', 'warning');
return;
}
if (cleanDest.startsWith(cleanP + '/') || cleanDest.startsWith(cleanP + '\\')) {
showToast('Cannot transfer a directory into its own subdirectory', 'error');
return;
}
}
if (sourcePane === targetPaneIndex && !subfolderPath) {
showToast('Source and destination are identical', 'warning');
return;
}
pendingInterpaneTransfer = {
sourcePane,
paths,
destination: destPath,
targetPaneIndex
};
if (e.shiftKey) {
executeInterpaneDrop('move');
return;
} else if (e.altKey || (e.ctrlKey && !e.metaKey)) {
executeInterpaneDrop('copy');
return;
}
const defaultAction = App.dndDefaultAction || 'ask';
const promptMode = App.dndPromptMode || 'ask';
if (defaultAction !== 'ask' && promptMode === 'direct') {
executeInterpaneDrop(defaultAction);
} else {
const msgEl = document.getElementById('interpane-drop-msg');
if (msgEl) {
msgEl.innerHTML = `Transfer <strong>${paths.length} item(s)</strong> to: <br><code style="background:var(--bg-dark); padding:3px 6px; border-radius:4px; display:inline-block; margin-top:6px; word-break:break-all;">${escapeHtml(destPath)}</code>`;
}
const btnMove = document.getElementById('btn-interpane-move');
const btnCopy = document.getElementById('btn-interpane-copy');
if (btnMove && btnCopy) {
if (defaultAction === 'move') {
btnMove.className = 'btn btn-accent';
btnCopy.className = 'btn';
} else {
btnCopy.className = 'btn btn-accent';
btnMove.className = 'btn';
}
}
showModal('interpane-drop-modal');
}
} catch (err) {
console.error('Inter-pane transfer error:', err);
}
}
}
function executeInterpaneDrop(action) {
closeModal('interpane-drop-modal');
if (!pendingInterpaneTransfer) return;
const { sourcePane, paths, destination, targetPaneIndex } = pendingInterpaneTransfer;
pendingInterpaneTransfer = null;
if (App.paranoidMode && App.dndParanoidPrompt) {
showParanoidConfirm(action, paths, destination, () => {
executeTransfer(action, paths, destination, targetPaneIndex, sourcePane);
}, sourcePane);
} else {
executeTransfer(action, paths, destination, targetPaneIndex, sourcePane);
}
}
async function trackTransferTask(taskId, action, sources, destination, onCompleteCallback, targetEndpoint = '', targetHeaders = null) {
if (!taskId) {
refreshAllPanes();
return;
}
// Clear any optimistic placeholder tasks now that server task ID is verified
lastKnownTasksList = lastKnownTasksList.filter(t => !t.id || !t.id.startsWith('opt_'));
requestTasksFastBurst(6000);
let checkCount = 0;
let lastProcessed = -1;
async function check() {
try {
const authHeaders = targetHeaders || {};
if (!targetHeaders) {
const token = App.token || localStorage.getItem('cd_token') || '';
if (token) {
authHeaders['Authorization'] = `Bearer ${token}`;
}
}
const url = targetEndpoint ? `${targetEndpoint}/api/tasks/${taskId}` : `/api/tasks/${taskId}`;
const resp = await fetch(url, {
headers: authHeaders
});
if (resp.ok) {
const task = await resp.json();
task.targetEndpoint = targetEndpoint;
task.targetHeaders = targetHeaders;
// Immediately sync local task state and render UI
const existingIdx = lastKnownTasksList.findIndex(t => t.id === taskId);
if (existingIdx !== -1) {
lastKnownTasksList[existingIdx] = task;
} else {
lastKnownTasksList.unshift(task);
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
if (task.status === 'completed') {
showToast(`${action === 'move' ? 'Moved' : 'Copied'} ${sources.length} item(s)`, 'success');
refreshAllPanes();
pollTasks();
if (typeof onCompleteCallback === 'function') onCompleteCallback(true);
return;
} else if (task.status === 'failed' || task.status === 'cancelled') {
showToast(`Transfer ${task.status}: ${task.error_message || 'Operation failed'}`, 'error');
refreshAllPanes();
pollTasks();
if (typeof onCompleteCallback === 'function') onCompleteCallback(false);
return;
} else {
// Still running - update visible panes if files are processed incrementally
if (task.files_processed !== lastProcessed) {
lastProcessed = task.files_processed;
refreshAllPanes();
}
requestTasksFastBurst(3000);
checkCount++;
setTimeout(check, 60);
return;
}
}
} catch (_) {}
checkCount++;
if (checkCount < 100) {
setTimeout(check, 80);
} else {
refreshAllPanes();
}
}
setTimeout(check, 20);
}
async function executeCrossNodeTransfer(action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx) {
const srcIdx = (typeof sourcePaneIdx === 'number' && App.panes[sourcePaneIdx]) ? sourcePaneIdx : App.activePaneIndex;
const destIdx = (typeof refreshTargetPaneIdx === 'number' && App.panes[refreshTargetPaneIdx]) ? refreshTargetPaneIdx : (srcIdx + 1) % getVisiblePaneCount();
const srcPane = App.panes[srcIdx];
const destPane = App.panes[destIdx];
if (!srcPane || !destPane || !sources || sources.length === 0) return;
const srcNode = getPaneNode(srcIdx);
const destNode = getPaneNode(destIdx);
const srcEndpoint = getPaneEndpoint(srcIdx);
const srcHeaders = getPaneAuthHeaders(srcIdx);
const destEndpoint = getPaneEndpoint(destIdx);
const destHeaders = getPaneAuthHeaders(destIdx);
const totalItems = sources.length;
showToast(`Starting cross-node ${action === 'move' ? 'Move' : 'Copy'} (${srcNode.name} ➔ ${destNode.name}): ${totalItems} item(s)...`, 'info');
const taskId = `xnode_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const xnodeTask = {
id: taskId,
name: `Cross-Node ${action === 'move' ? 'Move' : 'Copy'} (${srcNode.name} ➔ ${destNode.name})`,
action: action,
source: sources[0] + (sources.length > 1 ? ` (+${sources.length - 1} more)` : ''),
destination: `${destNode.name}:${destination}`,
total_files: sources.length,
files_processed: 0,
total_bytes: 0,
bytes_processed: 0,
current_file: '',
current_file_bytes: 0,
current_file_total_bytes: 0,
speed_bytes_per_sec: 0,
status: 'running',
paranoid: !!App.paranoidMode,
verified_files: 0,
last_hash: null,
log_entries: [
`[${new Date().toLocaleTimeString()}] Starting cross-node ${action} from ${srcNode.name} (${srcEndpoint || 'local'}) to ${destNode.name} (${destEndpoint || 'local'})`
],
started_at: Math.floor(Date.now() / 1000),
created_at: new Date().toISOString()
};
lastKnownTasksList.unshift(xnodeTask);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
if (App.autoOpenTasks && !App.taskManagerVisible) {
openFloatingTaskManager();
}
requestTasksFastBurst(6000);
const pill = document.getElementById('tasks-pill');
const pillText = document.getElementById('tasks-pill-text');
if (pill && pillText) {
pill.style.display = 'flex';
pillText.textContent = `Streaming ${action} ${srcNode.name} ➔ ${destNode.name}...`;
}
let successCount = 0;
let failCount = 0;
let totalBatchBytesDoneBefore = 0;
for (let i = 0; i < sources.length; i++) {
const srcPath = sources[i];
const fileName = srcPath.split('/').filter(Boolean).pop() || 'transferred_file';
xnodeTask.current_file = fileName;
xnodeTask.phase = 'download';
xnodeTask.phase_text = `📥 Downloading [${i + 1}/${totalItems}]: ${fileName}`;
xnodeTask.current_file_bytes = 0;
xnodeTask.current_file_total_bytes = 0;
if (pillText) {
pillText.textContent = `📥 Downloading [${i + 1}/${totalItems}] ${fileName}...`;
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
try {
// 1. Download stream from Source Node with real-time chunk progress
const dlUrl = `${srcEndpoint}/api/fs/download?path=${encodeURIComponent(srcPath)}`;
const downloadResp = await fetch(dlUrl, {
method: 'GET',
headers: srcHeaders
});
if (!downloadResp.ok) {
throw new Error(`Source download failed (${downloadResp.status}): ${await downloadResp.text()}`);
}
const contentLength = parseInt(downloadResp.headers.get('content-length') || '0', 10);
if (contentLength > 0) {
xnodeTask.current_file_total_bytes = contentLength;
if (xnodeTask.total_bytes === 0 || xnodeTask.total_bytes < totalBatchBytesDoneBefore + contentLength) {
xnodeTask.total_bytes = totalBatchBytesDoneBefore + contentLength;
}
}
const reader = downloadResp.body.getReader();
const chunks = [];
let receivedBytes = 0;
const downloadStart = performance.now();
let lastDlProgress = performance.now();
while (true) {
if (xnodeTask.status === 'cancelled') {
try { reader.cancel(); } catch(_) {}
throw new Error('Transfer cancelled by user');
}
while (xnodeTask.status === 'paused') {
await new Promise(r => setTimeout(r, 120));
if (xnodeTask.status === 'cancelled') {
try { reader.cancel(); } catch(_) {}
throw new Error('Transfer cancelled by user');
}
}
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
receivedBytes += value.length;
xnodeTask.current_file_bytes = receivedBytes;
const now = performance.now();
if (now - lastDlProgress >= 35) {
lastDlProgress = now;
const elapsedSec = (now - downloadStart) / 1000;
if (elapsedSec > 0.05) {
xnodeTask.speed_bytes_per_sec = Math.round(receivedBytes / elapsedSec);
}
xnodeTask.bytes_processed = totalBatchBytesDoneBefore + Math.round(receivedBytes * 0.5);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
}
const blob = new Blob(chunks);
xnodeTask.current_file_total_bytes = blob.size;
xnodeTask.total_bytes = Math.max(xnodeTask.total_bytes, totalBatchBytesDoneBefore + blob.size);
// SHA-256 integrity hash calculation
const srcSha256 = await computeSha256Hex(blob);
if (srcSha256) {
xnodeTask.last_hash = `SHA-256 Match: ${srcSha256}`;
xnodeTask.paranoid = true;
}
// 2. Upload to Destination Node with XHR progress monitoring
xnodeTask.phase = 'upload';
xnodeTask.phase_text = `📤 Uploading [${i + 1}/${totalItems}]: ${fileName}`;
if (pillText) {
pillText.textContent = `📤 Uploading [${i + 1}/${totalItems}] ${fileName}...`;
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
const uploadStart = performance.now();
let lastUpProgress = performance.now();
let destResponseData = null;
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const uploadUrl = `${destEndpoint}/api/fs/upload?destination=${encodeURIComponent(destination)}&no_task=1`;
xhr.open('POST', uploadUrl);
for (const h in destHeaders) {
xhr.setRequestHeader(h, destHeaders[h]);
}
xhr.upload.onprogress = (e) => {
if (xnodeTask.status === 'cancelled') {
xhr.abort();
reject(new Error('Transfer cancelled by user'));
return;
}
if (e.lengthComputable) {
xnodeTask.current_file_bytes = e.loaded;
xnodeTask.current_file_total_bytes = e.total;
const now = performance.now();
if (now - lastUpProgress >= 35) {
lastUpProgress = now;
const elapsedSec = (now - uploadStart) / 1000;
if (elapsedSec > 0.05) {
xnodeTask.speed_bytes_per_sec = Math.round(e.loaded / elapsedSec);
}
xnodeTask.bytes_processed = totalBatchBytesDoneBefore + Math.round(blob.size * 0.5) + Math.round(e.loaded * 0.5);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
destResponseData = JSON.parse(xhr.responseText);
} catch (_) {}
resolve();
} else {
reject(new Error(`Target upload failed (${xhr.status}): ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network upload error to target node'));
xhr.onabort = () => reject(new Error('Upload aborted'));
const formData = new FormData();
formData.append('files', blob, fileName);
xhr.send(formData);
});
const destSha256 = (destResponseData && destResponseData.hashes && destResponseData.hashes[fileName]) ? destResponseData.hashes[fileName] : null;
const finalHash = destSha256 || srcSha256;
if (finalHash) {
xnodeTask.last_hash = `SHA-256 Match: ${finalHash}`;
xnodeTask.verified_files = (xnodeTask.verified_files || 0) + 1;
xnodeTask.paranoid = true;
}
// 3. Delete from Source if Move operation
if (action === 'move') {
const delResp = await fetch(`${srcEndpoint}/api/fs/delete`, {
method: 'POST',
headers: getPaneAuthHeaders(srcIdx, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
paths: [srcPath],
use_trash: false
})
});
if (!delResp.ok) {
console.warn(`Source deletion failed after move:`, await delResp.text());
}
}
totalBatchBytesDoneBefore += blob.size;
xnodeTask.files_processed++;
xnodeTask.bytes_processed = totalBatchBytesDoneBefore;
const hashLog = xnodeTask.last_hash ? ` [${xnodeTask.last_hash}]` : '';
xnodeTask.log_entries.push(`[${new Date().toLocaleTimeString()}] ✓ Transferred ${fileName} (${formatBytes(blob.size)})${hashLog}`);
successCount++;
} catch (err) {
console.error(`Cross-node transfer error for ${srcPath}:`, err);
failCount++;
xnodeTask.log_entries.push(`[${new Date().toLocaleTimeString()}] ❌ Error on ${fileName}: ${err.message || err}`);
showToast(`Transfer failed for ${fileName}: ${err.message || err}`, 'error');
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
xnodeTask.status = failCount > 0 ? (successCount > 0 ? 'completed' : 'failed') : 'completed';
xnodeTask.speed_bytes_per_sec = 0;
xnodeTask.finished_at = Math.floor(Date.now() / 1000);
xnodeTask.bytes_processed = xnodeTask.total_bytes;
xnodeTask.log_entries.push(`[${new Date().toLocaleTimeString()}] Cross-node transfer finished: ${successCount} succeeded, ${failCount} failed`);
if (action === 'move' && srcPane.selected) {
srcPane.selected.clear();
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
refreshAllPanes();
if (failCount === 0) {
showToast(`Cross-node ${action === 'move' ? 'Move' : 'Copy'} completed: ${successCount} item(s) transferred to ${destNode.name}`, 'success');
} else {
showToast(`Cross-node transfer finished: ${successCount} succeeded, ${failCount} failed`, 'warning');
}
}
// ---------------- DESTINATION COLLISION & CONFLICT RESOLUTION ----------------
let pendingConflictBatch = null;
/**
* Detects filename collisions between incoming sources and destination pane entries.
*/
function detectCollisions(sources, destEntries, destPath) {
if (!sources || !destEntries || destEntries.length === 0) return [];
const destMap = new Map();
for (const entry of destEntries) {
if (entry && entry.name) {
destMap.set(entry.name.toLowerCase(), entry);
}
}
const collisions = [];
for (const src of sources) {
let srcName = '';
let srcSize = null;
let srcMtime = null;
let srcPath = null;
let srcFile = null;
if (typeof src === 'string') {
srcPath = src;
srcName = src.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || src;
for (const p of App.panes) {
if (p && p.entries) {
const match = p.entries.find(e => e.path === src || e.name === srcName);
if (match) {
srcSize = match.size;
srcMtime = match.mtime;
break;
}
}
}
} else if (src instanceof File) {
srcFile = src;
srcName = src.name;
srcSize = src.size;
srcMtime = src.lastModified ? Math.floor(src.lastModified / 1000) : null;
} else if (src && typeof src === 'object') {
srcName = src.name || '';
srcSize = src.size;
srcMtime = src.mtime;
srcPath = src.path;
srcFile = src.file || null;
}
if (srcName && destMap.has(srcName.toLowerCase())) {
const destEntry = destMap.get(srcName.toLowerCase());
collisions.push({
name: srcName,
srcPath,
srcFile,
srcSize,
srcMtime,
destItem: destEntry
});
}
}
return collisions;
}
/**
* Updates the Conflict Resolution Modal with current collision item metadata
*/
function renderCurrentConflictModal() {
if (!pendingConflictBatch || !pendingConflictBatch.collisions || pendingConflictBatch.collisions.length === 0) {
closeModal('conflict-resolution-modal');
return;
}
const idx = pendingConflictBatch.currentIndex;
const total = pendingConflictBatch.collisions.length;
const current = pendingConflictBatch.collisions[idx];
if (!current) {
closeModal('conflict-resolution-modal');
return;
}
const nameEl = document.getElementById('conflict-file-name');
const badgeEl = document.getElementById('conflict-counter-badge');
const srcSizeEl = document.getElementById('conflict-src-size');
const srcMtimeEl = document.getElementById('conflict-src-mtime');
const destSizeEl = document.getElementById('conflict-dest-size');
const destMtimeEl = document.getElementById('conflict-dest-mtime');
const remainingCountEl = document.getElementById('conflict-remaining-count');
const applyAllWrapper = document.getElementById('conflict-apply-all-wrapper');
const applyAllChk = document.getElementById('conflict-apply-all-chk');
if (nameEl) nameEl.textContent = current.name;
if (badgeEl) badgeEl.textContent = `${idx + 1} of ${total}`;
if (srcSizeEl) {
srcSizeEl.textContent = current.srcSize !== null && current.srcSize !== undefined ? formatBytes(current.srcSize) : 'Unknown';
}
if (srcMtimeEl) {
srcMtimeEl.textContent = current.srcMtime ? (typeof formatMtime === 'function' ? formatMtime(current.srcMtime) : new Date(current.srcMtime * 1000).toLocaleString()) : 'Unknown';
}
if (destSizeEl) {
destSizeEl.textContent = current.destItem && current.destItem.size !== undefined ? formatBytes(current.destItem.size) : 'Unknown';
}
if (destMtimeEl) {
destMtimeEl.textContent = current.destItem && current.destItem.mtime ? (typeof formatMtime === 'function' ? formatMtime(current.destItem.mtime) : new Date(current.destItem.mtime * 1000).toLocaleString()) : 'Unknown';
}
const remaining = total - idx - 1;
if (remainingCountEl) remainingCountEl.textContent = remaining;
if (applyAllWrapper) {
applyAllWrapper.style.display = total > 1 ? 'flex' : 'none';
}
if (applyAllChk) {
applyAllChk.checked = false;
}
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons();
}
}
/**
* Handle user's resolution decision on the current conflict item
*/
async function resolveConflictChoice(choice) {
if (!pendingConflictBatch) {
closeModal('conflict-resolution-modal');
return;
}
if (choice === 'cancel') {
closeModal('conflict-resolution-modal');
pendingConflictBatch = null;
showToast('Transfer cancelled', 'info');
return;
}
const applyAll = document.getElementById('conflict-apply-all-chk')?.checked;
const idx = pendingConflictBatch.currentIndex;
const total = pendingConflictBatch.collisions.length;
if (applyAll) {
for (let i = idx; i < total; i++) {
const col = pendingConflictBatch.collisions[i];
pendingConflictBatch.decisions[col.name.toLowerCase()] = choice;
}
pendingConflictBatch.currentIndex = total;
} else {
const col = pendingConflictBatch.collisions[idx];
if (col) {
pendingConflictBatch.decisions[col.name.toLowerCase()] = choice;
}
pendingConflictBatch.currentIndex++;
}
if (pendingConflictBatch.currentIndex < total) {
renderCurrentConflictModal();
return;
}
// All conflicts resolved!
closeModal('conflict-resolution-modal');
const batch = pendingConflictBatch;
pendingConflictBatch = null;
if (batch.uploadType === 'direct' || batch.uploadType === 'dnd') {
executeUploadWithDecisions(batch);
} else {
executeTransferWithDecisions(batch);
}
}
/**
* Executes a file transfer after all conflict decisions have been made
*/
async function executeTransferWithDecisions(batch) {
const { action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx, decisions } = batch;
if (!sources || sources.length === 0) return;
const activeSources = [];
let hasRenames = false;
for (const src of sources) {
const name = typeof src === 'string' ? src.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : (src.name || '');
const decision = decisions[name.toLowerCase()] || 'overwrite';
if (decision === 'skip') {
continue;
}
if (decision === 'rename') {
hasRenames = true;
}
activeSources.push(src);
}
if (activeSources.length === 0) {
showToast('All conflicting items were skipped. Nothing transferred.', 'info');
return;
}
const conflictMode = hasRenames ? 'rename' : 'overwrite';
await executeTransferDirect(action, activeSources, destination, refreshTargetPaneIdx, sourcePaneIdx, conflictMode);
}
/**
* Executes an upload batch after all conflict decisions have been made
*/
async function executeUploadWithDecisions(batch) {
const { files, destination, refreshTargetPaneIdx, decisions } = batch;
if (!files || files.length === 0) return;
const activeFiles = [];
let hasRenames = false;
for (const file of files) {
const name = file.name || '';
const decision = decisions[name.toLowerCase()] || 'overwrite';
if (decision === 'skip') {
continue;
}
if (decision === 'rename') {
hasRenames = true;
}
activeFiles.push(file);
}
if (activeFiles.length === 0) {
showToast('All conflicting upload items were skipped.', 'info');
return;
}
const conflictMode = hasRenames ? 'rename' : 'overwrite';
const paneIdx = (typeof refreshTargetPaneIdx === 'number' && App.panes[refreshTargetPaneIdx]) ? refreshTargetPaneIdx : App.activePaneIndex;
const endpoint = getPaneEndpoint(paneIdx);
const headers = getPaneAuthHeaders(paneIdx);
const formData = new FormData();
let totalUploadBytes = 0;
for (const f of activeFiles) {
formData.append('files', f);
totalUploadBytes += (f.size || 0);
}
// Zero-latency optimistic task dispatch
const optId = `opt_up_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const optTask = {
id: optId,
name: `Upload ${activeFiles.length} file(s)`,
status: 'running',
source: activeFiles[0].name + (activeFiles.length > 1 ? ` (+${activeFiles.length - 1} more)` : ''),
destination: destination,
total_files: activeFiles.length,
files_processed: 0,
total_bytes: totalUploadBytes,
bytes_processed: 0,
speed_bytes_per_sec: 0,
paranoid: false,
created_at: new Date().toISOString()
};
lastKnownTasksList.unshift(optTask);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
triggerAutoOpenTasksIfNeeded(350);
requestTasksFastBurst(6000);
const pill = document.getElementById('tasks-pill');
const pillText = document.getElementById('tasks-pill-text');
if (pill && pillText) {
pill.classList.add('active');
pill.style.display = 'flex';
pillText.textContent = `Uploading ${activeFiles.length} file(s)...`;
}
try {
const uploadUrl = `${endpoint}/api/fs/upload?destination=${encodeURIComponent(destination)}&conflict=${encodeURIComponent(conflictMode)}`;
const resp = await fetch(uploadUrl, {
method: 'POST',
body: formData,
headers: headers
});
if (resp.ok) {
showToast(`Uploaded ${activeFiles.length} file(s) successfully!`, 'success');
refreshAllPanes();
} else {
showToast(`Upload failed: ${await resp.text()}`, 'error');
}
} catch (err) {
showToast(`Upload error: ${err.message || err}`, 'error');
} finally {
lastKnownTasksList = lastKnownTasksList.filter(t => t.id !== optId);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
if (pill) {
pill.classList.remove('active');
pill.style.display = 'none';
}
if (typeof pollTasks === 'function') pollTasks();
}
}
async function executeTransferDirect(action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx, conflictMode) {
if (!sources || sources.length === 0) return;
const srcIdx = (typeof sourcePaneIdx === 'number' && App.panes[sourcePaneIdx]) ? sourcePaneIdx : App.activePaneIndex;
const destIdx = (typeof refreshTargetPaneIdx === 'number' && App.panes[refreshTargetPaneIdx]) ? refreshTargetPaneIdx : (srcIdx + 1) % getVisiblePaneCount();
const srcNode = getPaneNode(srcIdx);
const destNode = getPaneNode(destIdx);
// Client VFS Local Transfer detected!
if (sources.some(s => typeof s === 'string' && s.startsWith('client://')) || (typeof destination === 'string' && destination.startsWith('client://'))) {
return executeClientLocalTransfer(action, sources, destination, destIdx, srcIdx);
}
// Cross-Node Transfer detected!
if (srcNode.id !== destNode.id) {
return executeCrossNodeTransfer(action, sources, destination, destIdx, srcIdx);
}
// Zero-latency optimistic task dispatch for instant UI response (<1ms)
const optId = `opt_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const optTask = {
id: optId,
name: `${action === 'move' ? 'Move' : 'Copy'} ${sources.length} item(s)`,
status: 'running',
source: sources[0] + (sources.length > 1 ? ` (+${sources.length - 1} more)` : ''),
destination: destination,
total_files: sources.length,
files_processed: 0,
total_bytes: 0,
bytes_processed: 0,
speed_bytes_per_sec: 0,
paranoid: !!App.paranoidMode,
created_at: new Date().toISOString()
};
lastKnownTasksList.unshift(optTask);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
if (App.autoOpenTasks && !App.taskManagerVisible) {
openFloatingTaskManager();
}
requestTasksFastBurst(6000);
const baseEndpoint = getPaneEndpoint(srcIdx);
const endpoint = action === 'move' ? `${baseEndpoint}/api/fs/move` : `${baseEndpoint}/api/fs/copy`;
const headers = getPaneAuthHeaders(srcIdx, { 'Content-Type': 'application/json' });
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
sources,
destination,
paranoid: App.paranoidMode,
conflict_resolution: conflictMode || 'overwrite'
})
});
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
// Immediately clear selection on source pane for move operations
if (action === 'move') {
const srcPane = App.panes[srcIdx];
if (srcPane && srcPane.selected) {
srcPane.selected.clear();
}
}
// Trigger immediate pane refresh across all panels for instant feedback
refreshAllPanes();
if (data.task_id) {
trackTransferTask(data.task_id, action, sources, destination, null, baseEndpoint, headers);
} else {
lastKnownTasksList = lastKnownTasksList.filter(t => t.id !== optId);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
showToast(`${action === 'move' ? 'Moved' : 'Copied'} ${sources.length} item(s)`, 'success');
setTimeout(() => refreshAllPanes(), 80);
setTimeout(() => refreshAllPanes(), 300);
}
} else {
lastKnownTasksList = lastKnownTasksList.filter(t => t.id !== optId);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
showToast(`Transfer failed: ${await resp.text()}`, 'error');
refreshAllPanes();
}
} catch (err) {
lastKnownTasksList = lastKnownTasksList.filter(t => t.id !== optId);
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
showToast(`Transfer error: ${err.message || err}`, 'error');
refreshAllPanes();
}
}
async function executeTransfer(action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx, conflictMode) {
if (!sources || sources.length === 0) return;
if (conflictMode) {
return executeTransferDirect(action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx, conflictMode);
}
const destIdx = (typeof refreshTargetPaneIdx === 'number' && App.panes[refreshTargetPaneIdx])
? refreshTargetPaneIdx
: ((typeof sourcePaneIdx === 'number' ? sourcePaneIdx : App.activePaneIndex) + 1) % getVisiblePaneCount();
const destPane = App.panes[destIdx];
const destEntries = (destPane && destPane.entries && destPane.path === destination) ? destPane.entries : [];
const collisions = detectCollisions(sources, destEntries, destination);
if (collisions.length > 0) {
pendingConflictBatch = {
action,
sources,
destination,
refreshTargetPaneIdx: destIdx,
sourcePaneIdx: (typeof sourcePaneIdx === 'number') ? sourcePaneIdx : App.activePaneIndex,
collisions,
currentIndex: 0,
decisions: {},
uploadType: null
};
renderCurrentConflictModal();
showModal('conflict-resolution-modal');
return;
}
return executeTransferDirect(action, sources, destination, refreshTargetPaneIdx, sourcePaneIdx, 'overwrite');
}
// ---------------- KEYBOARD NAVIGATION & SHORTCUTS ----------------
/**
* Parse a human-readable shortcut string (e.g. "Ctrl+Shift+H", "Ctrl+Alt+C") into token structure
*/
function parseKeyboardShortcut(shortcutStr) {
if (!shortcutStr || typeof shortcutStr !== 'string') return null;
const tokens = shortcutStr.trim().split('+').map(t => t.trim().toLowerCase()).filter(Boolean);
if (tokens.length === 0) return null;
let ctrl = false;
let alt = false;
let shift = false;
let meta = false;
let mainKey = '';
for (const token of tokens) {
if (token === 'ctrl' || token === 'control') {
ctrl = true;
} else if (token === 'alt' || token === 'option') {
alt = true;
} else if (token === 'shift') {
shift = true;
} else if (token === 'meta' || token === 'cmd' || token === 'command' || token === 'win' || token === 'super') {
meta = true;
} else {
mainKey = token;
}
}
if (!mainKey) return null;
return { ctrl, alt, shift, meta, key: mainKey };
}
/**
* Match a KeyboardEvent against a declared shortcut string
*/
function matchKeyboardShortcut(e, shortcutStr) {
const parsed = parseKeyboardShortcut(shortcutStr);
if (!parsed) return false;
// Modifiers matching
if (parsed.ctrl && parsed.meta) {
if (!e.ctrlKey || !e.metaKey) return false;
} else if (parsed.ctrl) {
if (!e.ctrlKey && !e.metaKey) return false;
} else if (parsed.meta) {
if (!e.metaKey) return false;
} else {
if (e.ctrlKey || e.metaKey) return false;
}
if (parsed.alt !== !!e.altKey) return false;
if (parsed.shift !== !!e.shiftKey) return false;
const eventKey = (e.key || '').toLowerCase();
const eventCode = (e.code || '').toLowerCase();
const targetKey = parsed.key;
if (eventKey === targetKey) return true;
if (targetKey === 'space' && (eventKey === ' ' || eventCode === 'space')) return true;
if (targetKey === 'esc' && (eventKey === 'escape' || eventCode === 'escape')) return true;
if (eventCode === `key${targetKey}`) return true;
if (eventCode === `digit${targetKey}`) return true;
if (eventCode === targetKey) return true;
return false;
}
function setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
// Global Spotlight Trigger (Ctrl+K, Cmd+K, Ctrl+P)
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K' || e.key === 'p' || e.key === 'P')) {
e.preventDefault();
toggleSpotlightModal();
return;
}
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
if (e.key === 'Escape') {
hideContextMenu();
closeModal();
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'q' || e.key === 'Q')) {
e.preventDefault();
confirmExitBrum();
return;
}
return;
}
if (e.key === 'Escape') {
let handled = false;
const popups = document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover');
if (popups.length > 0) {
popups.forEach(p => p.remove());
handled = true;
}
const ctxMenu = document.getElementById('context-menu');
if (ctxMenu && ctxMenu.style.display === 'block') {
hideContextMenu();
handled = true;
}
const activeModal = document.querySelector('.modal-wrapper.active, .modal.active, .dialog-backdrop');
if (activeModal) {
closeModal();
handled = true;
}
const pane = App.panes[App.activePaneIndex];
if (!handled && pane && pane.selected && pane.selected.size > 0) {
pane.selected.clear();
renderPaneTable(App.activePaneIndex);
updateMobileBottomBar();
return;
}
if (!handled) {
hideContextMenu();
closeModal();
}
return;
}
// Dynamic ChewToys & Plugins Global Keyboard Shortcuts Dispatcher (.grr)
const activeChewToys = (window.installedChewToys || []).filter(p => p.is_enabled !== false && p.enabled !== false);
for (const plugin of activeChewToys) {
const shortcut = plugin.manifest?.integrations?.shortcut || plugin.integrations?.shortcut;
if (shortcut && matchKeyboardShortcut(e, shortcut)) {
e.preventDefault();
toggleDynamicChewToy(plugin.id);
return;
}
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'q' || e.key === 'Q') {
e.preventDefault();
confirmExitBrum();
return;
}
if (e.altKey && (e.key === 'l' || e.key === 'L')) {
e.preventDefault();
lockSession();
return;
}
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
triggerCopyClipboard();
return;
}
if (e.key === 'x' || e.key === 'X') {
e.preventDefault();
triggerCutClipboard();
return;
}
if (e.key === 'v' || e.key === 'V') {
e.preventDefault();
triggerPaste(App.activePaneIndex);
return;
}
if (e.key === 'b' || e.key === 'B') {
e.preventDefault();
toggleBranchView(App.activePaneIndex);
return;
}
if (e.key === 't' || e.key === 'T') {
e.preventDefault();
if (e.shiftKey) {
toggleFolderTree();
} else {
createPaneTab(App.activePaneIndex);
}
return;
}
if (e.key === 'w' || e.key === 'W') {
e.preventDefault();
const p = App.panes[App.activePaneIndex];
if (p && p.tabs && p.tabs.length > 1) {
closePaneTab(App.activePaneIndex, p.activeTabIndex);
}
return;
}
if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) prevPaneTab(App.activePaneIndex);
else nextPaneTab(App.activePaneIndex);
return;
}
if (e.key === 'PageDown') {
e.preventDefault();
nextPaneTab(App.activePaneIndex);
return;
}
if (e.key === 'PageUp') {
e.preventDefault();
prevPaneTab(App.activePaneIndex);
return;
}
if (e.key >= '1' && e.key <= '9') {
const tabNum = parseInt(e.key, 10) - 1;
const p = App.panes[App.activePaneIndex];
if (p && p.tabs && tabNum < p.tabs.length) {
e.preventDefault();
switchPaneTab(App.activePaneIndex, tabNum);
return;
}
}
if (e.key === 'm' || e.key === 'M') {
e.preventDefault();
openBatchRenamer();
return;
}
}
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
toggleFolderTree();
return;
}
// Modal-specific file action and navigation overrides
const imgModal = document.getElementById('image-viewer-modal');
if (imgModal && imgModal.classList.contains('active')) {
if (e.key === 'ArrowLeft') { e.preventDefault(); navImageViewer(-1); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); navImageViewer(1); return; }
if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomImage(0.2); return; }
if (e.key === '-') { e.preventDefault(); zoomImage(-0.2); return; }
if (e.key === '0') { e.preventDefault(); resetImageTransform(); return; }
if (e.key.toLowerCase() === 'r') { e.preventDefault(); rotateImage(90); return; }
if (e.key === 'F8' || e.key === 'Delete') { e.preventDefault(); imageViewerDeleteCurrent(); return; }
if (e.key === 'F6') { e.preventDefault(); imageViewerMoveCurrent(); return; }
if (e.key === 'F5') { e.preventDefault(); imageViewerCopyCurrent(); return; }
if (e.key === 'F2') { e.preventDefault(); imageViewerRenameCurrent(); return; }
}
const docModal = document.getElementById('doc-viewer-modal');
if (docModal && docModal.classList.contains('active')) {
if (e.key === 'F8' || e.key === 'Delete') { e.preventDefault(); docViewerDeleteCurrent(); return; }
if (e.key === 'F6') { e.preventDefault(); docViewerMoveCurrent(); return; }
if (e.key === 'F5') { e.preventDefault(); docViewerCopyCurrent(); return; }
if (e.key === 'F2') { e.preventDefault(); docViewerRenameCurrent(); return; }
}
switch (e.key) {
case 'Tab':
e.preventDefault();
const count = getVisiblePaneCount();
setActivePane((App.activePaneIndex + (e.shiftKey ? -1 : 1) + count) % count);
break;
case 'F1': e.preventDefault(); openHelpModal(); break;
case 'F2': e.preventDefault(); triggerRename(); break;
case 'F3': e.preventDefault(); triggerView(); break;
case 'F4': e.preventDefault(); triggerEditor(); break;
case 'F5': e.preventDefault(); triggerCopy(); break;
case 'F6': e.preventDefault(); triggerMove(); break;
case 'F7': e.preventDefault(); triggerMkdir(); break;
case 'F8':
case 'Delete': e.preventDefault(); triggerDelete(); break;
case 'F9': e.preventDefault(); triggerDiff(); break;
case 'F10': e.preventDefault(); openSettingsModal(); break;
case 'F12': e.preventDefault(); lockSession(); break;
case 'ArrowDown':
e.preventDefault();
if (pane.cursorIndex < pane.entries.length - 1) {
const prevCursor = pane.cursorIndex;
pane.cursorIndex++;
if (e.shiftKey) {
const anchor = (typeof pane.anchorIndex === 'number' && pane.anchorIndex >= 0 && pane.anchorIndex < pane.entries.length)
? pane.anchorIndex
: prevCursor;
pane.anchorIndex = anchor;
const start = Math.min(anchor, pane.cursorIndex);
const end = Math.max(anchor, pane.cursorIndex);
if (!e.ctrlKey && !e.metaKey) pane.selected.clear();
for (let i = start; i <= end; i++) {
if (pane.entries[i]) pane.selected.add(pane.entries[i].path);
}
} else if (!e.ctrlKey && !e.metaKey) {
pane.anchorIndex = pane.cursorIndex;
pane.selected.clear();
if (pane.entries[pane.cursorIndex]) pane.selected.add(pane.entries[pane.cursorIndex].path);
}
renderPaneTable(App.activePaneIndex);
}
break;
case 'ArrowUp':
e.preventDefault();
if (pane.cursorIndex > 0) {
const prevCursor = pane.cursorIndex;
pane.cursorIndex--;
if (e.shiftKey) {
const anchor = (typeof pane.anchorIndex === 'number' && pane.anchorIndex >= 0 && pane.anchorIndex < pane.entries.length)
? pane.anchorIndex
: prevCursor;
pane.anchorIndex = anchor;
const start = Math.min(anchor, pane.cursorIndex);
const end = Math.max(anchor, pane.cursorIndex);
if (!e.ctrlKey && !e.metaKey) pane.selected.clear();
for (let i = start; i <= end; i++) {
if (pane.entries[i]) pane.selected.add(pane.entries[i].path);
}
} else if (!e.ctrlKey && !e.metaKey) {
pane.anchorIndex = pane.cursorIndex;
pane.selected.clear();
if (pane.entries[pane.cursorIndex]) pane.selected.add(pane.entries[pane.cursorIndex].path);
}
renderPaneTable(App.activePaneIndex);
}
break;
case 'Enter':
e.preventDefault();
if (e.altKey) {
triggerProperties();
break;
}
if (pane.entries[pane.cursorIndex]) {
const entry = pane.entries[pane.cursorIndex];
if (entry.is_dir || entry.is_archive) loadPaneDirectory(App.activePaneIndex, entry.path);
else openEditorWithFile(entry.path);
}
break;
case 'Backspace':
e.preventDefault();
navPaneUp(App.activePaneIndex);
break;
case ' ':
case 'Insert':
e.preventDefault();
if (pane.entries[pane.cursorIndex]) {
const item = pane.entries[pane.cursorIndex];
if (pane.selected.has(item.path)) pane.selected.delete(item.path);
else pane.selected.add(item.path);
if (pane.cursorIndex < pane.entries.length - 1) pane.cursorIndex++;
renderPaneTable(App.activePaneIndex);
}
break;
case '/':
e.preventDefault();
togglePaneFilter(App.activePaneIndex);
break;
}
if (e.altKey && ['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
const targetIdx = parseInt(e.key) - 1;
if (targetIdx < getVisiblePaneCount()) setActivePane(targetIdx);
}
if ((e.ctrlKey || e.metaKey) && e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
toggleSyncScroll();
return;
}
if ((e.ctrlKey || e.metaKey) && e.altKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
toggleSyncNav();
return;
}
if (e.ctrlKey && e.key.toLowerCase() === 'd') {
e.preventDefault();
triggerDiff();
}
if (e.ctrlKey && (e.key === '`' || e.key === '~' || e.key === '\\')) {
e.preventDefault();
toggleTerminal();
}
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
openSearchModal();
}
if ((e.shiftKey && e.key === 'F6') || (e.ctrlKey && e.key.toLowerCase() === 'm')) {
e.preventDefault();
triggerBulkRename();
}
});
}
function getVisiblePaneCount() {
const vp = getViewportType();
const maxPanes = App.phoneTabletMaxPanes !== undefined && App.phoneTabletMaxPanes !== null
? parseInt(App.phoneTabletMaxPanes, 10)
: parseInt(localStorage.getItem('cd_phone_tablet_max_panes') || '2', 10);
const effectiveMax = isNaN(maxPanes) ? 2 : Math.max(1, Math.min(4, maxPanes));
if (vp === 'phone') {
// Phone viewport renders effectiveMax (default 2) panels in DOM to slide/swipe between
return effectiveMax;
}
const currentLayout = App.layout || getLayoutForViewport(vp);
let layoutCount = 4;
if (currentLayout === 'layout-single') layoutCount = 1;
else if (currentLayout === 'layout-dual-vertical' || currentLayout === 'layout-dual-horizontal') layoutCount = 2;
else if (currentLayout === 'layout-triple' || currentLayout === 'layout-triple-stacked') layoutCount = 3;
else layoutCount = 4;
if (vp === 'tablet') {
return Math.min(layoutCount, effectiveMax);
}
return layoutCount;
}
// ---------------- DUAL-PANE EDITOR & CODE VIEWER (SYNTAX HIGHLIGHTING & FIND/REPLACE) ----------------
function detectLanguageFromPath(path) {
const ext = (path.split('.').pop() || '').toLowerCase();
const map = {
'rs': 'rust',
'py': 'python',
'js': 'javascript',
'ts': 'typescript',
'jsx': 'javascript',
'tsx': 'typescript',
'html': 'html',
'htm': 'html',
'xml': 'xml',
'css': 'css',
'scss': 'css',
'json': 'json',
'yaml': 'yaml',
'yml': 'yaml',
'toml': 'toml',
'sh': 'bash',
'bash': 'bash',
'zsh': 'bash',
'sql': 'sql',
'md': 'markdown',
'markdown': 'markdown',
'c': 'clike',
'cpp': 'clike',
'h': 'clike',
'hpp': 'clike',
'dockerfile': 'docker',
};
return map[ext] || 'markup';
}
// ========================================================
// 📝 REVISED MULTI-TAB DEVELOPER POWER EDITOR & CONF.D ASSEMBLER
// ========================================================
// ========================================================
// 📝 FLOATING MULTI-TAB POWER DEVELOPER EDITOR & CONF.D ASSEMBLER
// ========================================================
let editorTabs = [];
let activeTabIdLeft = null;
let activeTabIdRight = null;
let editorActivePane = 'left';
let editorViewMode = 'single-editor'; // 'single-editor', 'dual-vertical', 'dual-horizontal', 'split-markdown'
let editorTabCounter = 1;
let editorTabSize = 2;
let editorFindMatches = [];
let currentMatchIndex = -1;
let findOptions = { matchCase: false, matchWord: false, useRegex: false };
let pendingConfdDirPath = null;
let pendingConfdFiles = [];
let topFloatingZIndex = 1600;
function bringFloatingWindowToFront(el) {
if (!el) return;
topFloatingZIndex++;
el.style.zIndex = topFloatingZIndex;
}
function getActiveEditorTab(pane = 'left') {
const tabId = pane === 'left' ? activeTabIdLeft : activeTabIdRight;
return editorTabs.find(t => t.id === tabId) || editorTabs[0] || null;
}
function getEditorTabById(id) {
return editorTabs.find(t => t.id === id) || null;
}
function createNewEditorTab(initialContent = '', defaultName = null, filePath = null, isConfd = false, confdFiles = []) {
const tabId = 'tab_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
const name = defaultName || (filePath ? getBasename(filePath) : `Untitled-${editorTabCounter++}`);
const detectedLang = filePath ? detectLanguageFromPath(filePath) : 'markup';
const newTab = {
id: tabId,
path: filePath,
filename: name,
content: initialContent,
origContent: initialContent,
isDirty: false,
lang: detectedLang,
isConfdAssembled: isConfd,
confdFiles: confdFiles,
cursor: { line: 1, col: 1 },
scrollTop: 0
};
editorTabs.push(newTab);
switchActiveEditorTab(tabId, 'left');
renderEditorTabs();
return newTab;
}
function openFloatingEditor() {
closeToolsMenu();
if (editorTabs.length === 0) {
createNewEditorTab();
}
const win = document.getElementById('floating-editor-window');
const pill = document.getElementById('editor-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initEditorDragResize();
renderEditorTabs();
const leftTab = getActiveEditorTab('left');
if (leftTab) {
switchActiveEditorTab(leftTab.id, 'left');
}
}
function restoreFloatingEditor() {
openFloatingEditor();
}
function minimizeFloatingEditor() {
const win = document.getElementById('floating-editor-window');
const pill = document.getElementById('editor-pill');
const pillText = document.getElementById('editor-pill-text');
const pillDirty = document.getElementById('editor-pill-dirty');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
const dirtyCount = editorTabs.filter(t => t.isDirty).length;
const tabCount = editorTabs.length;
if (pillText) pillText.textContent = `EditorDog (${tabCount} file${tabCount === 1 ? '' : 's'})`;
if (pillDirty) pillDirty.style.display = dirtyCount > 0 ? 'inline-block' : 'none';
if (window.lucide) lucide.createIcons();
}
}
function maximizeFloatingEditor() {
const win = document.getElementById('floating-editor-window');
if (!win) return;
win.classList.toggle('maximized');
}
function jumpEditorToLine(lineNum, targetPane = 'left') {
const textarea = document.getElementById(`editor-text-${targetPane}`);
if (!textarea || !lineNum) return;
const target = parseInt(lineNum, 10);
if (isNaN(target) || target < 1) return;
const lines = textarea.value.split('\n');
let pos = 0;
for (let i = 0; i < Math.min(target - 1, lines.length); i++) {
pos += lines[i].length + 1;
}
const end = pos + (lines[target - 1] ? lines[target - 1].length : 0);
textarea.focus();
textarea.setSelectionRange(pos, end);
textarea.scrollTop = Math.max(0, (target - 5) * 19);
if (typeof syncGutterScroll === 'function') syncGutterScroll(targetPane);
}
async function openEditorWithFile(filePath, paneIndex = null, lineNumber = null) {
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const cleanPath = sanitizeCredentials(filePath);
const existingTab = editorTabs.find(t => (t.path === cleanPath || t.path === filePath) && (t.paneIndex === resolvedPaneIdx || t.paneIndex === undefined));
if (existingTab) {
switchActiveEditorTab(existingTab.id, 'left');
const dockedIdx = App.panes.findIndex(p => p.dockedTool === 'editor');
if (dockedIdx !== -1) {
renderDockedPaneTool(dockedIdx);
} else {
openFloatingEditor();
}
if (lineNumber) {
setTimeout(() => jumpEditorToLine(lineNumber, 'left'), 60);
}
return;
}
try {
let content = '';
if (filePath.startsWith('client://')) {
const fileHandle = await resolveClientFileHandle(filePath);
const file = await fileHandle.getFile();
content = await file.text();
} else {
const authPath = resolveAuthUri(filePath);
const endpoint = getPaneEndpoint(resolvedPaneIdx);
const authHeaders = getPaneAuthHeaders(resolvedPaneIdx);
const resp = await fetch(`${endpoint}/api/fs/read?path=${encodeURIComponent(authPath)}`, {
headers: { ...authHeaders }
});
if (!resp.ok) {
showToast('Failed to read file: ' + sanitizeCredentials(await resp.text()), 'error');
return;
}
const data = await resp.json();
content = data.content;
}
const tab = createNewEditorTab(content, null, cleanPath, false, []);
tab.paneIndex = resolvedPaneIdx;
const isMd = cleanPath.endsWith('.md') || cleanPath.endsWith('.markdown');
handleEditorViewModeChange(isMd ? 'split-markdown' : 'single-editor');
const dockedIdx = App.panes.findIndex(p => p.dockedTool === 'editor');
if (dockedIdx !== -1) {
renderDockedPaneTool(dockedIdx);
} else {
openFloatingEditor();
}
if (lineNumber) {
setTimeout(() => jumpEditorToLine(lineNumber, 'left'), 60);
}
} catch (e) {
showToast('Read error: ' + sanitizeCredentials(String(e)), 'error');
}
}
// ---------------- CONF.D MODULAR CONFIG ASSEMBLER ----------------
async function checkAndPromptConfdDirectory(dirPath) {
try {
const res = await fetch(`/api/fs/list?path=${encodeURIComponent(dirPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const data = await res.json();
const files = data.entries.filter(e => !e.is_dir);
if (files.length > 0) {
pendingConfdDirPath = dirPath;
pendingConfdFiles = files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
const dirNameEl = document.getElementById('confd-modal-dirname');
if (dirNameEl) dirNameEl.textContent = getBasename(dirPath);
const listEl = document.getElementById('confd-modal-filelist');
if (listEl) {
listEl.innerHTML = pendingConfdFiles.map(f => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 2px 0;">
<span>📄 <b>${escapeHtml(f.name)}</b></span>
<span style="color: var(--text-dim); font-size: 10px;">${formatBytes(f.size)}</span>
</div>
`).join('');
}
showModal('confd-assembler-modal');
return;
}
}
} catch (e) {
console.error('conf.d check error:', e);
}
openFloatingEditor();
}
async function executeOpenConfdAssembled() {
closeModal('confd-assembler-modal');
if (!pendingConfdFiles || pendingConfdFiles.length === 0) return;
const confdFolderName = getBasename(pendingConfdDirPath) || 'conf.d';
showToast(`Assembling ${pendingConfdFiles.length} config files...`, 'info');
const assembledParts = [];
for (const f of pendingConfdFiles) {
try {
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(f.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
let content = '';
if (resp.ok) {
const data = await resp.json();
content = data.content;
}
assembledParts.push({
path: f.path,
filename: f.name,
content: content
});
} catch (err) {
console.warn('Failed reading conf.d part:', f.path, err);
}
}
let fullBuffer = `# =========================================================================\n# 🧩 BRUM MODULAR CONFIG COMPILATION: ${confdFolderName}\n# Directory: ${pendingConfdDirPath}\n# Assembled: ${assembledParts.length} files in execution order\n# Edits are saved individually to each respective file on Ctrl+S.\n# =========================================================================\n\n`;
for (const part of assembledParts) {
fullBuffer += `# =========================================================================\n`;
fullBuffer += `# 📁 [FILE START: ${part.filename}] (${part.path})\n`;
fullBuffer += `# =========================================================================\n`;
fullBuffer += part.content.endsWith('\n') ? part.content : part.content + '\n';
fullBuffer += `# =========================================================================\n`;
fullBuffer += `# 🏁 [FILE END: ${part.filename}]\n`;
fullBuffer += `# =========================================================================\n\n`;
}
const tab = createNewEditorTab(fullBuffer, `${confdFolderName} [Assembled]`, pendingConfdDirPath, true, assembledParts);
populateConfdJumpDropdown(tab);
handleEditorViewModeChange('single-editor');
openFloatingEditor();
showToast(`Assembled ${assembledParts.length} files into unified config view!`, 'success');
}
async function executeOpenConfdSeparateTabs() {
closeModal('confd-assembler-modal');
if (!pendingConfdFiles || pendingConfdFiles.length === 0) return;
for (const f of pendingConfdFiles) {
await openEditorWithFile(f.path);
}
openFloatingEditor();
}
function populateConfdJumpDropdown(tab) {
const jumpSel = document.getElementById('editor-confd-jump');
const statusConfd = document.getElementById('editor-confd-status');
const sepConfd = document.getElementById('editor-confd-sep');
if (!jumpSel) return;
if (tab && tab.isConfdAssembled && tab.confdFiles && tab.confdFiles.length > 0) {
jumpSel.style.display = 'inline-block';
if (statusConfd) statusConfd.style.display = 'inline-block';
if (sepConfd) sepConfd.style.display = 'inline-block';
jumpSel.innerHTML = `<option value="">Jump to Section (${tab.confdFiles.length})...</option>` +
tab.confdFiles.map(f => `<option value="${escapeHtml(f.filename)}">📁 ${escapeHtml(f.filename)}</option>`).join('');
} else {
jumpSel.style.display = 'none';
if (statusConfd) statusConfd.style.display = 'none';
if (sepConfd) sepConfd.style.display = 'none';
}
}
function jumpToConfdSection(filename) {
if (!filename) return;
const textarea = document.getElementById('editor-text-left');
if (!textarea) return;
const targetHeader = `[FILE START: ${filename}]`;
const text = textarea.value;
const index = text.indexOf(targetHeader);
if (index !== -1) {
textarea.focus();
textarea.setSelectionRange(index, index + targetHeader.length);
const linesBefore = text.substring(0, index).split('\n').length;
const lineHeight = 19;
textarea.scrollTop = Math.max(0, (linesBefore - 4) * lineHeight);
syncGutterScroll('left');
handleEditorInput('left');
}
}
// ---------------- TAB SWITCHING & RENDERING ----------------
function switchActiveEditorTab(tabId, targetPane = 'left') {
const targetTab = getEditorTabById(tabId);
if (!targetTab) return;
const prevTabId = targetPane === 'left' ? activeTabIdLeft : activeTabIdRight;
if (prevTabId) {
const prevTab = getEditorTabById(prevTabId);
const textarea = document.getElementById(`editor-text-${targetPane}`);
if (prevTab && textarea) {
prevTab.content = textarea.value;
prevTab.scrollTop = textarea.scrollTop;
prevTab.selectionStart = textarea.selectionStart;
prevTab.selectionEnd = textarea.selectionEnd;
}
}
if (targetPane === 'left') activeTabIdLeft = tabId;
else activeTabIdRight = tabId;
const textarea = document.getElementById(`editor-text-${targetPane}`);
const titleEl = document.getElementById(`editor-file-title-${targetPane}`);
const tagEl = document.getElementById(`editor-file-tag-${targetPane}`);
if (textarea) {
textarea.value = targetTab.content;
textarea.scrollTop = targetTab.scrollTop || 0;
if (targetTab.selectionStart !== undefined) {
textarea.selectionStart = targetTab.selectionStart;
textarea.selectionEnd = targetTab.selectionEnd;
}
}
if (titleEl) {
titleEl.textContent = targetTab.path ? sanitizeCredentials(targetTab.path) : targetTab.filename;
titleEl.title = targetTab.path || targetTab.filename;
}
if (tagEl) {
tagEl.textContent = targetTab.isConfdAssembled ? 'CONFD' : (targetTab.lang ? targetTab.lang.toUpperCase() : 'TEXT');
}
const langBadge = document.getElementById('editor-language-badge');
if (langBadge && targetPane === 'left') {
langBadge.textContent = formatEditorLangBadge(targetTab.lang, targetTab.isConfdAssembled);
}
const langSelect = document.getElementById('editor-language-select');
if (langSelect && targetPane === 'left') langSelect.value = targetTab.lang || 'auto';
const mobileLangSelect = document.getElementById('editor-language-select-mobile');
if (mobileLangSelect && targetPane === 'left') mobileLangSelect.value = targetTab.lang || 'auto';
populateConfdJumpDropdown(targetTab);
updateEditorGutter(targetPane);
handleEditorInput(targetPane);
renderEditorTabs();
}
async function closeEditorTab(tabId, force = false) {
const tabIndex = editorTabs.findIndex(t => t.id === tabId);
if (tabIndex === -1) return;
const tab = editorTabs[tabIndex];
if (tab.isDirty && !force) {
const confirmed = await showConfirmDialog({
title: 'Unsaved Changes',
subtitle: tab.filename,
message: `"${tab.filename}" has unsaved changes. Close without saving?`,
icon: 'alert-circle',
type: 'warning',
confirmText: 'Discard & Close',
cancelText: 'Keep Editing'
});
if (!confirmed) {
return;
}
}
editorTabs.splice(tabIndex, 1);
if (editorTabs.length === 0) {
createNewEditorTab();
} else {
if (activeTabIdLeft === tabId) {
const nextIndex = Math.min(tabIndex, editorTabs.length - 1);
switchActiveEditorTab(editorTabs[nextIndex].id, 'left');
}
if (activeTabIdRight === tabId) {
activeTabIdRight = editorTabs[0]?.id || null;
}
}
renderEditorTabs();
}
async function closeEditorModal() {
const dirtyCount = editorTabs.filter(t => t.isDirty).length;
if (dirtyCount > 0) {
const confirmed = await showConfirmDialog({
title: 'Unsaved Changes in Editor',
subtitle: `${dirtyCount} Modified File(s)`,
message: `You have ${dirtyCount} file(s) with unsaved changes. Close editor without saving?`,
icon: 'alert-triangle',
type: 'warning',
confirmText: 'Close Without Saving',
cancelText: 'Keep Editing'
});
if (!confirmed) {
return;
}
}
const win = document.getElementById('floating-editor-window');
const pill = document.getElementById('editor-pill');
if (win) win.style.display = 'none';
if (pill) pill.style.display = 'none';
}
let editorDragInitialized = false;
function initEditorDragResize() {
if (editorDragInitialized) return;
editorDragInitialized = true;
const win = document.getElementById('floating-editor-window');
const header = document.getElementById('editor-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_editor_x');
const savedTop = localStorage.getItem('cd_editor_y');
const savedWidth = localStorage.getItem('cd_editor_w');
const savedHeight = localStorage.getItem('cd_editor_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(360, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(240, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
win.style.right = 'auto';
win.style.bottom = 'auto';
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_editor_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_editor_y', parseInt(win.style.top, 10));
}
});
setupFloatingWindowResizers(win);
}
function setupFloatingWindowResizers(win) {
const handles = {
top: document.getElementById('editor-resize-top'),
bottom: document.getElementById('editor-resize-bottom'),
left: document.getElementById('editor-resize-left'),
right: document.getElementById('editor-resize-right'),
corner: document.getElementById('editor-resize-corner')
};
let resizeMode = null;
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
if (win.classList.contains('maximized')) return;
resizeMode = mode;
bringFloatingWindowToFront(win);
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
if (mode === 'top' || mode === 'bottom') document.body.style.cursor = 'ns-resize';
else if (mode === 'left' || mode === 'right') document.body.style.cursor = 'ew-resize';
else document.body.style.cursor = 'nwse-resize';
});
});
window.addEventListener('mousemove', (e) => {
if (!resizeMode) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (resizeMode === 'bottom' || resizeMode === 'corner') {
const newH = Math.max(240, Math.min(window.innerHeight - startTop - 20, startH + dy));
win.style.height = `${newH}px`;
}
if (resizeMode === 'right' || resizeMode === 'corner') {
const newW = Math.max(360, Math.min(window.innerWidth - startLeft - 20, startW + dx));
win.style.width = `${newW}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(360, startW - dx);
const newLeft = startLeft + (startW - newW);
if (newLeft >= 0) {
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
}
if (resizeMode === 'top') {
const newH = Math.max(240, startH - dy);
const newTop = startTop + (startH - newH);
if (newTop >= 35) {
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
});
window.addEventListener('mouseup', () => {
if (resizeMode) {
resizeMode = null;
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem('cd_editor_w', win.offsetWidth);
localStorage.setItem('cd_editor_h', win.offsetHeight);
if (win.style.left) localStorage.setItem('cd_editor_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_editor_y', parseInt(win.style.top, 10));
}
});
}
// ==========================================================================
// 🧮 FLOATING CALCULATOR & BYTE MATH ENGINE
// ==========================================================================
let calcExpr = '';
let calcCurrentVal = '0';
let calcHasEvaluated = false;
let calcHistory = JSON.parse(localStorage.getItem('cd_calc_history') || '[]');
let calcDragInitialized = false;
function openFloatingCalculator() {
closeToolsMenu();
const isInstalled = (window.installedChewToys || []).some(p => p.id === 'calculator' && (p.is_enabled !== undefined ? p.is_enabled : (p.enabled !== undefined ? p.enabled : true)));
if (isInstalled) {
openDynamicChewToy('calculator');
return;
}
const win = document.getElementById('floating-calculator-window');
const pill = document.getElementById('calc-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initCalcDrag();
calcUpdateDisplay();
renderCalcHistory();
}
function closeFloatingCalculator() {
const win = document.getElementById('floating-calculator-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('calc-pill');
if (pill) pill.style.display = 'none';
}
function minimizeFloatingCalculator() {
const win = document.getElementById('floating-calculator-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('calc-pill');
if (pill) pill.style.display = 'flex';
}
function restoreFloatingCalculator() {
openFloatingCalculator();
}
function initCalcDrag() {
if (calcDragInitialized) return;
calcDragInitialized = true;
const win = document.getElementById('floating-calculator-window');
const header = document.getElementById('calc-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_calc_x');
const savedTop = localStorage.getItem('cd_calc_y');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 300, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 380, Math.max(35, parseInt(savedTop, 10)))}px`;
} else if (window.innerWidth > 1024) {
win.style.right = '40px';
win.style.top = '100px';
}
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('input')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, winStartX + dx));
const newTop = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
win.style.right = 'auto';
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_calc_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_calc_y', parseInt(win.style.top, 10));
}
});
// Global key listener when calculator is active
document.addEventListener('keydown', (e) => {
if (win.style.display !== 'flex') return;
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) && e.target.id !== 'calc-display-input') return;
if (e.key >= '0' && e.key <= '9') {
calcAppendNum(e.key);
} else if (['+', '-', '*', '/'].includes(e.key)) {
calcAppendOp(e.key);
} else if (e.key === '.') {
calcAppendDot();
} else if (e.key === 'Enter' || e.key === '=') {
e.preventDefault();
calcEvaluate();
} else if (e.key === 'Backspace') {
calcBackspace();
} else if (e.key === 'Escape') {
if (calcExpr || calcCurrentVal !== '0') calcClearAll();
else closeFloatingCalculator();
} else if (e.key === '(' || e.key === ')') {
calcAppendChar(e.key);
} else if (e.key === '%') {
calcAppendOp('%');
}
});
}
function calcUpdateDisplay() {
document.querySelectorAll('#calc-expr-display, [id^="docked-calc-expr"]').forEach(el => {
el.textContent = calcExpr || '\u00A0';
});
document.querySelectorAll('#calc-display-input, [id^="docked-calc-display"]').forEach(el => {
el.value = calcCurrentVal;
});
const num = parseFloat(calcCurrentVal) || 0;
// Format Byte Size
const sizeStr = formatBytes(Math.abs(num));
document.querySelectorAll('#calc-conv-size, [id^="docked-calc-conv-size"]').forEach(el => {
el.textContent = sizeStr;
});
// Format Hexadecimal
let hexStr = '-';
if (!isNaN(num) && Math.abs(num) < 0x1000000000000) {
const intVal = Math.floor(Math.abs(num));
hexStr = (num < 0 ? '-' : '') + '0x' + intVal.toString(16).toUpperCase();
}
document.querySelectorAll('#calc-conv-hex, [id^="docked-calc-conv-hex"]').forEach(el => {
el.textContent = hexStr;
});
// Format Octal (useful for chmod!)
let octStr = '-';
if (!isNaN(num) && Math.abs(num) < 0x1000000) {
const intVal = Math.floor(Math.abs(num));
octStr = (num < 0 ? '-' : '') + '0' + intVal.toString(8);
}
document.querySelectorAll('#calc-conv-oct, [id^="docked-calc-conv-oct"]').forEach(el => {
el.textContent = octStr;
});
// Format Binary
let binStr = '-';
if (!isNaN(num) && Math.abs(num) <= 0xFFFF) {
const intVal = Math.floor(Math.abs(num));
binStr = (num < 0 ? '-' : '') + '0b' + intVal.toString(2);
}
document.querySelectorAll('#calc-conv-bin, [id^="docked-calc-conv-bin"]').forEach(el => {
el.textContent = binStr;
});
}
function calcAppendNum(digit) {
if (calcHasEvaluated) {
calcCurrentVal = digit;
calcExpr = '';
calcHasEvaluated = false;
} else if (calcCurrentVal === '0' || calcCurrentVal === '-0') {
calcCurrentVal = (calcCurrentVal === '-0' ? '-' : '') + digit;
} else {
calcCurrentVal += digit;
}
calcUpdateDisplay();
}
function calcAppendDot() {
if (calcHasEvaluated) {
calcCurrentVal = '0.';
calcExpr = '';
calcHasEvaluated = false;
} else if (!calcCurrentVal.includes('.')) {
calcCurrentVal += '.';
}
calcUpdateDisplay();
}
function calcAppendOp(op) {
calcHasEvaluated = false;
if (calcCurrentVal) {
calcExpr = calcExpr ? `${calcExpr} ${calcCurrentVal} ${op}` : `${calcCurrentVal} ${op}`;
calcCurrentVal = '0';
} else if (calcExpr && /[+\-*/%^]$/.test(calcExpr.trim())) {
calcExpr = calcExpr.trim().slice(0, -1) + op;
}
calcUpdateDisplay();
}
function calcAppendChar(ch) {
if (ch === '(') {
calcExpr = calcExpr ? `${calcExpr} (` : '(';
} else if (ch === ')') {
if (calcCurrentVal && calcCurrentVal !== '0') {
calcExpr = `${calcExpr} ${calcCurrentVal} )`;
calcCurrentVal = '0';
} else {
calcExpr = `${calcExpr} )`;
}
}
calcUpdateDisplay();
}
function calcAppendUnit(unit) {
const multipliers = {
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024,
};
const mult = multipliers[unit] || 1;
const currentNum = parseFloat(calcCurrentVal) || 1;
calcCurrentVal = String(currentNum * mult);
calcUpdateDisplay();
}
function calcAppendFunc(fnStr) {
calcExpr = calcExpr ? `${calcExpr} ${fnStr}` : fnStr;
calcUpdateDisplay();
}
function calcAppendPi() {
calcCurrentVal = String(Math.PI);
calcUpdateDisplay();
}
function calcToggleSign() {
if (calcCurrentVal.startsWith('-')) {
calcCurrentVal = calcCurrentVal.substring(1);
} else if (calcCurrentVal !== '0') {
calcCurrentVal = '-' + calcCurrentVal;
}
calcUpdateDisplay();
}
function calcBackspace() {
if (calcCurrentVal.length > 1) {
calcCurrentVal = calcCurrentVal.slice(0, -1);
} else {
calcCurrentVal = '0';
}
calcUpdateDisplay();
}
function calcClearEntry() {
calcCurrentVal = '0';
calcUpdateDisplay();
}
function calcClearAll() {
calcExpr = '';
calcCurrentVal = '0';
calcHasEvaluated = false;
calcUpdateDisplay();
}
function calcEvaluate() {
let fullExpr = (calcExpr ? `${calcExpr} ${calcCurrentVal}` : calcCurrentVal).trim();
if (!fullExpr) return;
try {
let sanitized = fullExpr
.replace(/÷/g, '/')
.replace(/×/g, '*')
.replace(/−/g, '-')
.replace(/\^/g, '**')
.replace(/sqrt\(/g, 'Math.sqrt(')
.replace(/sin\(/g, 'Math.sin(')
.replace(/cos\(/g, 'Math.cos(')
.replace(/tan\(/g, 'Math.tan(')
.replace(/abs\(/g, 'Math.abs(')
.replace(/π/g, 'Math.PI')
.replace(/e/g, 'Math.E');
// Only allow safe math tokens
if (!/^[0-9+\-*/%().,\sMath.sqrtincoabPIE*]+$/.test(sanitized)) {
throw new Error('Invalid expression');
}
const result = Function(`"use strict"; return (${sanitized});`)();
if (typeof result !== 'number' || isNaN(result) || !isFinite(result)) {
throw new Error('Math error');
}
const formattedResult = Number.isInteger(result) ? String(result) : parseFloat(result.toFixed(8)).toString();
// Save to calculation history
calcHistory.unshift({
expr: fullExpr,
result: formattedResult,
time: Date.now()
});
if (calcHistory.length > 30) calcHistory.pop();
localStorage.setItem('cd_calc_history', JSON.stringify(calcHistory));
calcExpr = fullExpr + ' =';
calcCurrentVal = formattedResult;
calcHasEvaluated = true;
calcUpdateDisplay();
renderCalcHistory();
} catch (err) {
calcCurrentVal = 'Error';
calcHasEvaluated = true;
calcUpdateDisplay();
}
}
function copyCalcResult() {
const val = calcCurrentVal;
if (val && val !== 'Error') {
navigator.clipboard.writeText(val).then(() => {
showToast(`📋 Copied "${val}" to clipboard`, 'success');
}).catch(() => {
showToast(`Value: ${val}`, 'info');
});
}
}
function toggleCalcHistory() {
const panel = document.getElementById('calc-history-panel');
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
}
function calcClearHistory() {
calcHistory = [];
localStorage.removeItem('cd_calc_history');
renderCalcHistory();
}
function renderCalcHistory() {
const listEl = document.getElementById('calc-history-list');
if (!listEl) return;
if (calcHistory.length === 0) {
listEl.innerHTML = '<div class="calc-history-empty" style="font-size: 10px; color: var(--text-dim); text-align: center; padding: 6px;">No calculations yet</div>';
return;
}
listEl.innerHTML = calcHistory.map(item => `
<div class="calc-history-item" onclick="calcLoadHistoryItem('${escapeHtml(item.result)}')">
<span style="color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;">${escapeHtml(item.expr)}</span>
<span style="font-weight: 700; color: var(--accent);">= ${escapeHtml(item.result)}</span>
</div>
`).join('');
}
function calcLoadHistoryItem(resVal) {
calcCurrentVal = resVal;
calcExpr = '';
calcHasEvaluated = false;
calcUpdateDisplay();
}
// =========================================================================
// 🐶 NOTEDOG CHEWTOY CLIENT ENGINE (Notes & Markdown Studio)
// =========================================================================
// 8. NOTEDOG - POWER NOTES STUDIO (PERSISTENT DB + FS BACKENDS)
// =========================================================================
const DEFAULT_MANUALS_NOTEBOOK = {
name: 'Manuals',
path: 'manual://',
is_encrypted: false,
sections: [
{
name: 'User Manuals',
path: 'manual://user-manuals',
notes: [
{ name: '01. Documentation Index', filename: 'README.md', path: 'manual://README.md', relative_path: 'Manuals/User Manuals/README.md', is_manual: true },
{ name: '02. Keyboard Shortcuts & Navigation', filename: 'shortcuts.md', path: 'manual://shortcuts.md', relative_path: 'Manuals/User Manuals/shortcuts.md', is_manual: true },
{ name: '03. Advanced Sharing & Client Portals', filename: 'sharing.md', path: 'manual://sharing.md', relative_path: 'Manuals/User Manuals/sharing.md', is_manual: true },
{ name: '04. Transparent Encrypted Vaults', filename: 'vaults.md', path: 'manual://vaults.md', relative_path: 'Manuals/User Manuals/vaults.md', is_manual: true },
{ name: '05. Remote Protocols & VFS Guide', filename: 'protocols.md', path: 'manual://protocols.md', relative_path: 'Manuals/User Manuals/protocols.md', is_manual: true },
{ name: '06. Design Specs & Amber Palette', filename: 'themes-and-palette.md', path: 'manual://themes-and-palette.md', relative_path: 'Manuals/User Manuals/themes-and-palette.md', is_manual: true }
]
},
{
name: 'Deployment & Config',
path: 'manual://deployment',
notes: [
{ name: '01. Configuration Reference (config.toml)', filename: 'configuration.md', path: 'manual://configuration.md', relative_path: 'Manuals/Deployment/configuration.md', is_manual: true },
{ name: '02. Docker & Portainer Deployment', filename: 'docker.md', path: 'manual://docker.md', relative_path: 'Manuals/Deployment/docker.md', is_manual: true },
{ name: '03. Proxmox VE & Linux LXC', filename: 'lxc-proxmox.md', path: 'manual://lxc-proxmox.md', relative_path: 'Manuals/Deployment/lxc-proxmox.md', is_manual: true },
{ name: '04. Reverse Proxy & Mesh VPN Guide', filename: 'reverse-proxy.md', path: 'manual://reverse-proxy.md', relative_path: 'Manuals/Deployment/reverse-proxy.md', is_manual: true },
{ name: '05. OpenID Connect & Authentik SSO', filename: 'authentik-sso.md', path: 'manual://authentik-sso.md', relative_path: 'Manuals/Deployment/authentik-sso.md', is_manual: true },
{ name: '06. Brum for Windows Guide', filename: 'windows.md', path: 'manual://windows.md', relative_path: 'Manuals/Deployment/windows.md', is_manual: true },
{ name: '07. Brum on Android & Termux', filename: 'android-termux.md', path: 'manual://android-termux.md', relative_path: 'Manuals/Deployment/android-termux.md', is_manual: true }
]
},
{
name: 'Chewtoys & Plugins',
path: 'manual://chewtoys',
notes: [
{ name: '01. Power Tools & Chewtoys Manual', filename: 'chewtoys.md', path: 'manual://chewtoys.md', relative_path: 'Manuals/Chewtoys/chewtoys.md', is_manual: true },
{ name: '02. Chewtoy Plugin Development (.grr)', filename: 'plugin-development.md', path: 'manual://plugin-development.md', relative_path: 'Manuals/Chewtoys/plugin-development.md', is_manual: true }
]
},
{
name: 'QA Testing Manuals',
path: 'manual://qa-testing',
notes: [
{ name: '01. QA Testing & Verification Manual', filename: 'qa-testing.md', path: 'manual://qa-testing.md', relative_path: 'Manuals/QA Testing/qa-testing.md', is_manual: true }
]
}
]
};
function getNoteDogManualsNotebook() {
const found = (notedogState.notebooks || []).find(nb => nb.path?.startsWith('manual://') || nb.name === 'Manuals');
return found || DEFAULT_MANUALS_NOTEBOOK;
}
function getManualSectionIcon(secName) {
const s = (secName || '').toLowerCase();
if (s.includes('qa') || s.includes('testing')) return '🧪';
if (s.includes('deployment') || s.includes('config')) return '🚀';
if (s.includes('chewtoy') || s.includes('plugin')) return '🧩';
return '📖';
}
const notedogState = {
isOpen: false,
isMaximized: false,
storageMode: localStorage.getItem('cd_notes_storage_mode') || 'database',
rootFolder: '',
customFolder: localStorage.getItem('cd_notedog_folder') || '',
// Filesystem notes
notebooks: [DEFAULT_MANUALS_NOTEBOOK],
activeNotebook: '',
activeSection: '',
activeNote: null,
// Database notes
dbNotes: [],
activeDbNote: null,
activeCategory: 'General',
activeDbSection: 'Default',
categories: ['General', 'Work', 'Personal', 'Projects', 'Archive', 'Manuals'],
sections: ['Default'],
activeAttachments: [],
// Common state
content: '',
isDirty: false,
viewMode: 'split',
searchQuery: '',
templates: [],
selectedVersion: null,
autoSaveTimer: null,
dragInitialized: false,
cachedPassphrases: {},
unlockedNotes: {},
pasteDropInitialized: false,
attachmentsTrayOpen: false,
};
function saveNoteDogFolderSetting(newFolder) {
const clean = (newFolder || '').trim();
if (clean) {
localStorage.setItem('cd_notedog_folder', clean);
notedogState.customFolder = clean;
showToast(`NoteDog notes folder set to: ${clean}`, 'success');
} else {
localStorage.removeItem('cd_notedog_folder');
notedogState.customFolder = '';
showToast('NoteDog notes folder reset to default', 'info');
}
if (notedogState.storageMode === 'filesystem') {
loadNoteDogHierarchy();
}
}
function browseNoteDogFolder() {
const activePane = App.panes[App.activePaneIndex];
if (activePane && activePane.path) {
const input = document.getElementById('setting-notedog-folder');
if (input) input.value = activePane.path;
saveNoteDogFolderSetting(activePane.path);
} else {
showToast('No active pane directory available', 'warning');
}
}
async function promptChangeNoteDogFolder() {
const current = notedogState.customFolder || notedogState.rootFolder || '~/Notes';
const newPath = await showPromptDialog({
title: 'Change Notes Folder Location',
subtitle: 'Configure root directory for filesystem notes',
message: 'Enter folder path:',
defaultValue: current,
placeholder: 'e.g. ~/Notes, /mnt/storage/Notes',
confirmText: 'Save'
});
if (newPath === null) return;
saveNoteDogFolderSetting(newPath);
}
function setNoteDogStorageMode(mode) {
notedogState.storageMode = mode;
localStorage.setItem('cd_notes_storage_mode', mode);
const win = document.getElementById('floating-notedog-window');
if (win) {
win.classList.toggle('db-mode', mode === 'database');
win.classList.toggle('fs-mode', mode === 'filesystem');
}
const btnDb = document.getElementById('btn-notedog-mode-db');
const btnFs = document.getElementById('btn-notedog-mode-fs');
if (btnDb) btnDb.classList.toggle('active', mode === 'database');
if (btnFs) btnFs.classList.toggle('active', mode === 'filesystem');
const tier1Title = document.getElementById('notedog-tier1-title');
if (tier1Title) tier1Title.textContent = mode === 'database' ? '📚 Categories' : '📚 Notebooks';
const metaBar = document.getElementById('notedog-meta-bar');
if (metaBar) metaBar.style.display = mode === 'database' ? 'flex' : 'none';
if (mode === 'database') {
loadDatabaseNotesHierarchy();
} else {
loadNoteDogHierarchy();
}
}
function openFloatingNoteDog(optionalNotePath) {
closeToolsMenu();
const win = document.getElementById('floating-notedog-window');
const pill = document.getElementById('notedog-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
notedogState.isOpen = true;
bringFloatingWindowToFront(win);
if (window.innerWidth <= 1024) {
if (optionalNotePath || notedogState.activeNote || notedogState.activeDbNote) {
closeNoteDogDrawer();
} else {
openNoteDogDrawer();
}
} else {
const isCollapsed = localStorage.getItem('cd_notedog_sidebar_collapsed') === 'true';
win.classList.toggle('sidebar-collapsed', isCollapsed);
updateNoteDogDrawerButtons(!isCollapsed);
}
}
initNoteDogDrag();
initNoteDogTouchGestures();
initNoteDogEditorPasteAndDrop();
setNoteDogStorageMode(notedogState.storageMode);
if (optionalNotePath) {
if (optionalNotePath.startsWith('manual://') && notedogState.storageMode === 'database') {
selectDatabaseCategory('Manuals');
}
setTimeout(() => {
selectNoteDogNoteByPath(optionalNotePath);
}, 120);
}
if (window.lucide) lucide.createIcons();
}
function toggleNoteDogDrawer() {
const win = document.getElementById('floating-notedog-window');
if (!win) return;
if (window.innerWidth <= 1024) {
const isOpen = win.classList.toggle('drawer-open');
updateNoteDogDrawerButtons(isOpen);
} else {
const isCollapsed = win.classList.toggle('sidebar-collapsed');
localStorage.setItem('cd_notedog_sidebar_collapsed', isCollapsed ? 'true' : 'false');
updateNoteDogDrawerButtons(!isCollapsed);
}
}
function openNoteDogDrawer() {
const win = document.getElementById('floating-notedog-window');
if (!win) return;
if (window.innerWidth <= 1024) {
win.classList.add('drawer-open');
updateNoteDogDrawerButtons(true);
} else {
win.classList.remove('sidebar-collapsed');
localStorage.setItem('cd_notedog_sidebar_collapsed', 'false');
updateNoteDogDrawerButtons(true);
}
}
function closeNoteDogDrawer() {
const win = document.getElementById('floating-notedog-window');
if (!win) return;
if (window.innerWidth <= 1024) {
win.classList.remove('drawer-open');
updateNoteDogDrawerButtons(false);
}
}
function updateNoteDogDrawerButtons(isOpen) {
const btnToggle = document.getElementById('btn-notedog-drawer-toggle');
const btnWs = document.getElementById('btn-notedog-workspace-drawer');
[btnToggle, btnWs].forEach(b => {
if (b) {
b.classList.toggle('active', !!isOpen);
b.title = isOpen ? 'Hide Notes Explorer' : 'Show Notes Explorer (Sidebar)';
}
});
}
let _notedogTouchInitialized = false;
function initNoteDogTouchGestures() {
if (_notedogTouchInitialized) return;
_notedogTouchInitialized = true;
const win = document.getElementById('floating-notedog-window');
if (!win) return;
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
win.addEventListener('touchstart', (e) => {
if (window.innerWidth > 1024) return;
if (!e.touches || e.touches.length === 0) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}, { passive: true });
win.addEventListener('touchend', (e) => {
if (window.innerWidth > 1024) return;
if (!e.changedTouches || e.changedTouches.length === 0) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const dx = touchEndX - touchStartX;
const dy = touchEndY - touchStartY;
const dt = Date.now() - touchStartTime;
// Horizontal swipe gesture detection (dx > 40px, dy < 60px, within 450ms)
if (Math.abs(dy) < 60 && dt < 450) {
const isDrawerOpen = win.classList.contains('drawer-open');
if (isDrawerOpen && dx < -40) {
// Swiping left closes the open drawer
closeNoteDogDrawer();
} else if (!isDrawerOpen && dx > 40 && touchStartX < 60) {
// Swiping right from left screen edge opens the drawer
openNoteDogDrawer();
}
}
}, { passive: true });
}
function showNoteDogWorkspaceMobile() {
closeNoteDogDrawer();
}
function showNoteDogSidebarMobile() {
openNoteDogDrawer();
}
function closeFloatingNoteDog() {
const win = document.getElementById('floating-notedog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('notedog-pill');
if (pill) pill.style.display = 'none';
notedogState.isOpen = false;
}
function minimizeFloatingNoteDog() {
const win = document.getElementById('floating-notedog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('notedog-pill');
if (pill) {
pill.style.display = 'flex';
const pillText = document.getElementById('notedog-pill-text');
if (pillText) {
pillText.textContent = notedogState.activeNote?.name || 'NoteDog';
}
}
notedogState.isOpen = false;
}
function restoreFloatingNoteDog() {
openFloatingNoteDog();
}
function maximizeFloatingNoteDog() {
const win = document.getElementById('floating-notedog-window');
if (!win) return;
notedogState.isMaximized = !notedogState.isMaximized;
win.classList.toggle('maximized', notedogState.isMaximized);
}
function dockNoteDogToActivePane() {
dockToolToPane('notedog', App.activePaneIndex);
}
function setupNoteDogWindowResizers(win) {
const handles = {
top: document.getElementById('notedog-resize-top'),
bottom: document.getElementById('notedog-resize-bottom'),
left: document.getElementById('notedog-resize-left'),
right: document.getElementById('notedog-resize-right'),
corner: document.getElementById('notedog-resize-corner')
};
let resizeMode = null;
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
if (win.classList.contains('maximized')) return;
e.preventDefault();
e.stopPropagation();
resizeMode = mode;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
const onMouseMove = (moveEvent) => {
if (!resizeMode) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const minW = 420;
const minH = 280;
if (resizeMode === 'right' || resizeMode === 'corner') {
const newW = Math.max(minW, startW + dx);
win.style.width = `${newW}px`;
}
if (resizeMode === 'bottom' || resizeMode === 'corner') {
const newH = Math.max(minH, startH + dy);
win.style.height = `${newH}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, startW - dx);
if (newW > minW) {
win.style.width = `${newW}px`;
win.style.left = `${startLeft + dx}px`;
}
}
if (resizeMode === 'top') {
const newH = Math.max(minH, startH - dy);
if (newH > minH) {
win.style.height = `${newH}px`;
win.style.top = `${startTop + dy}px`;
}
}
};
const onMouseUp = () => {
if (resizeMode) {
resizeMode = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
if (win.style.width) localStorage.setItem('cd_notedog_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_notedog_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_notedog_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_notedog_y', parseInt(win.style.top, 10));
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
});
}
function initNoteDogDrag() {
if (notedogState.dragInitialized) return;
notedogState.dragInitialized = true;
const win = document.getElementById('floating-notedog-window');
const header = document.getElementById('notedog-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_notedog_x');
const savedTop = localStorage.getItem('cd_notedog_y');
const savedWidth = localStorage.getItem('cd_notedog_w');
const savedHeight = localStorage.getItem('cd_notedog_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 400, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 300, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(420, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(280, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('input') || notedogState.isMaximized) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, winStartX + dx));
const newTop = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem('cd_notedog_x', win.offsetLeft);
localStorage.setItem('cd_notedog_y', win.offsetTop);
}
});
setupNoteDogWindowResizers(win);
}
// ---------------- DATABASE NOTES & ATTACHMENTS CLIENT ENGINE ----------------
async function loadDatabaseNotesHierarchy(targetNoteId) {
try {
let url = '/api/notes';
const params = [];
if (notedogState.searchQuery.trim()) {
params.push(`search=${encodeURIComponent(notedogState.searchQuery.trim())}`);
}
if (params.length > 0) url += `?${params.join('&')}`;
const resp = await fetch(url, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) return;
const notes = await resp.json();
notedogState.dbNotes = notes || [];
// Extract categories
const defaultCats = ['General', 'Work', 'Personal', 'Projects', 'Archive', 'Manuals'];
const foundCats = new Set(defaultCats);
notedogState.dbNotes.forEach(n => {
if (n.category) foundCats.add(n.category.replace(/^[^\w\s&]+/, '').trim());
});
notedogState.categories = Array.from(foundCats);
if (!notedogState.activeCategory || !notedogState.categories.includes(notedogState.activeCategory)) {
notedogState.activeCategory = notedogState.categories[0] || 'General';
}
// Extract sections in active category
if (notedogState.activeCategory === 'Manuals' || notedogState.activeCategory === '📖 Manuals') {
notedogState.activeCategory = 'Manuals';
const manualsNb = getNoteDogManualsNotebook();
notedogState.sections = (manualsNb.sections || []).map(s => s.name);
if (!notedogState.sections.includes(notedogState.activeDbSection)) {
notedogState.activeDbSection = notedogState.sections[0] || 'User Manuals';
}
} else {
const catNotes = notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === notedogState.activeCategory);
const foundSections = new Set(['Default']);
catNotes.forEach(n => {
if (n.section) foundSections.add(n.section.replace(/^[^\w\s&]+/, '').trim());
});
notedogState.sections = Array.from(foundSections);
if (!notedogState.activeDbSection || !notedogState.sections.includes(notedogState.activeDbSection)) {
notedogState.activeDbSection = notedogState.sections[0] || 'Default';
}
}
renderDatabaseSidebar();
// Select target note or matching note or first note
if (targetNoteId) {
const match = notedogState.dbNotes.find(n => n.id === targetNoteId || n.id === Number(targetNoteId));
if (match) {
return selectDatabaseNote(match);
}
}
if (notedogState.activeCategory === 'Manuals') {
const manualsNb = getNoteDogManualsNotebook();
const sec = manualsNb?.sections?.find(s => s.name === notedogState.activeDbSection || s.name.replace(/^[^\w\s&]+/, '').trim() === (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim());
if (sec && sec.notes.length > 0) {
notedogState.activeNote = { ...sec.notes[0], nbName: 'Manuals', secName: sec.name };
loadNoteDogNoteContent(notedogState.activeNote);
}
return;
}
const catNotes = notedogState.dbNotes.filter(n => n.category === notedogState.activeCategory);
const currentNotes = catNotes.filter(n => n.section === notedogState.activeDbSection);
if (currentNotes.length > 0) {
if (!notedogState.activeDbNote || !currentNotes.some(n => n.id === notedogState.activeDbNote.id)) {
selectDatabaseNote(currentNotes[0]);
} else {
const refreshed = notedogState.dbNotes.find(n => n.id === notedogState.activeDbNote.id);
if (refreshed) selectDatabaseNote(refreshed);
}
} else {
notedogState.activeDbNote = null;
notedogState.content = '';
notedogState.activeAttachments = [];
const titleInput = document.getElementById('notedog-active-title');
if (titleInput) titleInput.value = 'No notes in section';
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = '';
const preview = document.getElementById('notedog-preview-content');
if (preview) preview.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 40px;">Click <b>+ Note</b> to create a new database note!</div>';
renderAttachmentsTray();
}
} catch (err) {
console.error('Database notes load failed:', err);
}
}
function renderDatabaseSidebar() {
const nbList = document.getElementById('notedog-notebooks-list');
const secList = document.getElementById('notedog-sections-list');
const noteList = document.getElementById('notedog-notes-list');
// 1. Categories
if (nbList) {
nbList.innerHTML = notedogState.categories.map(rawCat => {
const cleanCat = (rawCat || '').replace(/^[^\w\s&]+/, '').trim();
const isManualCat = cleanCat === 'Manuals' || rawCat === '📖 Manuals';
const manualsNb = getNoteDogManualsNotebook();
const count = isManualCat ? (manualsNb ? manualsNb.sections.reduce((acc, s) => acc + s.notes.length, 0) : 16) : notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === cleanCat).length;
const isActive = cleanCat === (notedogState.activeCategory || '').replace(/^[^\w\s&]+/, '').trim();
const icon = isManualCat ? '📖' : '📚';
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectDatabaseCategory('${escapeHtml(cleanCat)}')" title="${escapeHtml(cleanCat)} (${count} notes)">
<span>${icon}</span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${escapeHtml(cleanCat)}</span>
<span class="notedog-item-count">${count}</span>
</div>
`;
}).join('');
}
// 2. Sections in active category
if (secList) {
const isManualCat = notedogState.activeCategory === 'Manuals' || notedogState.activeCategory === '📖 Manuals';
if (isManualCat) {
const manualsNb = getNoteDogManualsNotebook();
const secListDefs = manualsNb?.sections || [];
secList.innerHTML = secListDefs.map(sec => {
const count = sec.notes ? sec.notes.length : 0;
const cleanName = sec.name.replace(/^[^\w\s&]+/, '').trim();
const isActive = cleanName === (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim() || sec.name === notedogState.activeDbSection;
const icon = getManualSectionIcon(sec.name);
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectDatabaseSection('${escapeHtml(sec.name)}')" title="${escapeHtml(cleanName)} (${count} notes)">
<span>${icon}</span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${escapeHtml(cleanName)}</span>
<span class="notedog-item-count">${count}</span>
</div>
`;
}).join('');
} else {
const cleanActiveCat = (notedogState.activeCategory || '').replace(/^[^\w\s&]+/, '').trim();
const catNotes = notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === cleanActiveCat);
secList.innerHTML = notedogState.sections.map(sec => {
const cleanSec = sec.replace(/^[^\w\s&]+/, '').trim();
const count = catNotes.filter(n => (n.section || '').replace(/^[^\w\s&]+/, '').trim() === cleanSec).length;
const isActive = cleanSec === (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim();
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectDatabaseSection('${escapeHtml(cleanSec)}')" title="${escapeHtml(cleanSec)} (${count} notes)">
<span>📂</span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${escapeHtml(cleanSec)}</span>
<span class="notedog-item-count">${count}</span>
</div>
`;
}).join('');
}
}
// 3. Notes in active section or search results
if (noteList) {
const isManualCat = notedogState.activeCategory === 'Manuals' || notedogState.activeCategory === '📖 Manuals';
if (isManualCat) {
const manualsNb = getNoteDogManualsNotebook();
const currentSec = manualsNb?.sections?.find(s => s.name === notedogState.activeDbSection || s.name.replace(/^[^\w\s&]+/, '').trim() === (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim());
let notesToRender = currentSec ? currentSec.notes : [];
if (notedogState.searchQuery.trim()) {
const q = notedogState.searchQuery.toLowerCase();
notesToRender = [];
manualsNb?.sections?.forEach(sec => {
sec.notes.forEach(n => {
if (n.name.toLowerCase().includes(q) || n.filename.toLowerCase().includes(q)) {
notesToRender.push({ ...n, subtext: `Manuals/${sec.name.replace(/^[^\w\s&]+/, '').trim()}` });
}
});
});
}
if (notesToRender.length === 0) {
noteList.innerHTML = `<div style="padding: 10px; font-size: 11px; color: var(--text-dim); text-align: center;">No matching manual notes</div>`;
} else {
noteList.innerHTML = notesToRender.map(note => {
const isActive = notedogState.activeNote && (notedogState.activeNote.path === note.path);
const cleanTitle = note.name.replace(/^[^\w\s&]+/, '').trim();
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectNoteDogNoteByPath('${escapeHtml(note.path)}')" title="${escapeHtml(note.filename)}">
<span>📄</span>
<div style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<div>${escapeHtml(cleanTitle)}</div>
${note.subtext ? `<div style="font-size: 9px; color: var(--text-dim);">${escapeHtml(note.subtext)}</div>` : ''}
</div>
</div>
`;
}).join('');
}
} else {
let notesToRender = [];
const cleanActiveCat = (notedogState.activeCategory || '').replace(/^[^\w\s&]+/, '').trim();
const cleanActiveSec = (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim();
if (notedogState.searchQuery.trim()) {
notesToRender = notedogState.dbNotes;
} else {
notesToRender = notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === cleanActiveCat && (n.section || '').replace(/^[^\w\s&]+/, '').trim() === cleanActiveSec);
}
if (notesToRender.length === 0) {
noteList.innerHTML = `<div style="padding: 10px; font-size: 11px; color: var(--text-dim); text-align: center;">${notedogState.searchQuery ? 'No matching notes' : 'No notes in section'}</div>`;
} else {
noteList.innerHTML = notesToRender.map(note => {
const isActive = notedogState.activeDbNote && (notedogState.activeDbNote.id === note.id);
const pinIcon = note.is_pinned ? '📌' : '📄';
const colorDot = note.color ? `<span style="width: 8px; height: 8px; border-radius: 50%; background: ${note.color}; display: inline-block; flex-shrink: 0;"></span>` : '';
const attBadge = (note.attachments && note.attachments.length > 0) ? `<span style="font-size: 9px; color: var(--text-dim); display: inline-flex; align-items: center; gap: 2px;">📎 ${note.attachments.length}</span>` : '';
const subtext = notedogState.searchQuery.trim() ? `${note.category}/${note.section}` : '';
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectDatabaseNoteById(${note.id})" title="${escapeHtml(note.title)}">
<span>${pinIcon}</span>
${colorDot}
<div style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<div>${escapeHtml(note.title)}</div>
${subtext ? `<div style="font-size: 9px; color: var(--text-dim);">${escapeHtml(subtext)}</div>` : ''}
</div>
${attBadge}
</div>
`;
}).join('');
}
}
}
if (window.lucide) lucide.createIcons();
}
function selectDatabaseCategory(cat) {
const cleanCat = (cat || '').replace(/^[^\w\s&]+/, '').trim();
const isManual = cleanCat === 'Manuals';
notedogState.activeCategory = cleanCat;
if (isManual) {
const manualsNb = getNoteDogManualsNotebook();
notedogState.sections = (manualsNb.sections || []).map(s => s.name);
if (!notedogState.sections.includes(notedogState.activeDbSection)) {
notedogState.activeDbSection = notedogState.sections[0] || 'User Manuals';
}
renderDatabaseSidebar();
const sec = manualsNb?.sections?.find(s => s.name === notedogState.activeDbSection || s.name.replace(/^[^\w\s&]+/, '').trim() === (notedogState.activeDbSection || '').replace(/^[^\w\s&]+/, '').trim());
if (sec && sec.notes.length > 0) {
selectNoteDogNote({ ...sec.notes[0], nbName: 'Manuals', secName: sec.name });
}
return;
}
const catNotes = notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === cleanCat);
const foundSections = new Set(['Default']);
catNotes.forEach(n => { if (n.section) foundSections.add(n.section.replace(/^[^\w\s&]+/, '').trim()); });
notedogState.sections = Array.from(foundSections);
notedogState.activeDbSection = notedogState.sections[0] || 'Default';
renderDatabaseSidebar();
const currentNotes = catNotes.filter(n => (n.section || '').replace(/^[^\w\s&]+/, '').trim() === notedogState.activeDbSection);
if (currentNotes.length > 0) {
selectDatabaseNote(currentNotes[0]);
} else {
notedogState.activeDbNote = null;
notedogState.content = '';
notedogState.activeAttachments = [];
const titleInput = document.getElementById('notedog-active-title');
if (titleInput) titleInput.value = 'No notes in section';
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = '';
const preview = document.getElementById('notedog-preview-content');
if (preview) preview.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 40px;">Click <b>+ Note</b> to create a note!</div>';
renderAttachmentsTray();
}
}
function selectDatabaseSection(sec) {
const cleanSec = (sec || '').replace(/^[^\w\s&]+/, '').trim();
notedogState.activeDbSection = cleanSec;
renderDatabaseSidebar();
const isManual = (notedogState.activeCategory || '').replace(/^[^\w\s&]+/, '').trim() === 'Manuals';
if (isManual) {
const manualsNb = getNoteDogManualsNotebook();
const s = manualsNb?.sections?.find(it => it.name === sec || it.name.replace(/^[^\w\s&]+/, '').trim() === cleanSec);
if (s && s.notes.length > 0) {
selectNoteDogNote({ ...s.notes[0], nbName: 'Manuals', secName: s.name });
}
return;
}
const cleanCat = (notedogState.activeCategory || '').replace(/^[^\w\s&]+/, '').trim();
const currentNotes = notedogState.dbNotes.filter(n => (n.category || '').replace(/^[^\w\s&]+/, '').trim() === cleanCat && (n.section || '').replace(/^[^\w\s&]+/, '').trim() === cleanSec);
if (currentNotes.length > 0) {
selectDatabaseNote(currentNotes[0]);
} else {
notedogState.activeDbNote = null;
notedogState.content = '';
notedogState.activeAttachments = [];
const titleInput = document.getElementById('notedog-active-title');
if (titleInput) titleInput.value = 'No notes in section';
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = '';
const preview = document.getElementById('notedog-preview-content');
if (preview) preview.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 40px;">Click <b>+ Note</b> to create a note!</div>';
renderAttachmentsTray();
}
}
function selectDatabaseNoteById(id) {
const note = notedogState.dbNotes.find(n => n.id === id);
if (note) selectDatabaseNote(note);
}
function selectDatabaseNote(note) {
if (notedogState.isDirty && notedogState.activeDbNote) {
saveActiveDatabaseNote(true);
}
notedogState.activeDbNote = note;
notedogState.content = note.content || '';
notedogState.isDirty = false;
notedogState.activeAttachments = note.attachments || [];
const titleInput = document.getElementById('notedog-active-title');
if (titleInput) titleInput.value = note.title;
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = notedogState.content;
const saveStatus = document.getElementById('notedog-save-status');
if (saveStatus) {
saveStatus.textContent = 'Saved';
saveStatus.className = 'notedog-save-status';
}
const colorIndicator = document.getElementById('notedog-color-indicator');
if (colorIndicator) {
colorIndicator.style.background = note.color || 'transparent';
colorIndicator.style.border = note.color ? `1px solid ${note.color}` : '1px solid rgba(255,255,255,0.25)';
}
const pinBtn = document.getElementById('btn-pin-note');
if (pinBtn) {
pinBtn.classList.toggle('active', !!note.is_pinned);
pinBtn.style.color = note.is_pinned ? 'var(--accent)' : 'var(--text-muted)';
}
const catChip = document.getElementById('notedog-active-cat-chip');
if (catChip) catChip.textContent = note.category || 'General';
const secChip = document.getElementById('notedog-active-sec-chip');
if (secChip) secChip.textContent = note.section || 'Default';
const updatedTime = document.getElementById('notedog-updated-time');
if (updatedTime && note.updated_at) {
try {
const dt = new Date(note.updated_at);
updatedTime.textContent = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch(e) {}
}
const unlockCard = document.getElementById('notedog-unlock-card');
if (unlockCard) unlockCard.style.display = 'none';
renderDatabaseNoteTags(note);
renderAttachmentsTray();
syncNoteDogGutter();
renderNoteDogPreview(notedogState.content);
renderDatabaseSidebar();
if (window.innerWidth <= 1024) {
closeNoteDogDrawer();
}
}
function renderDatabaseNoteTags(note) {
const container = document.getElementById('notedog-tags-list');
if (!container) return;
let tags = [];
try {
tags = typeof note.tags === 'string' ? JSON.parse(note.tags || '[]') : (note.tags || []);
} catch(e) {
tags = [];
}
container.innerHTML = tags.map(t => `
<span class="notedog-tag-chip">
#${escapeHtml(t)}
<span class="del-tag" onclick="removeNoteTag('${escapeHtml(t)}')" title="Remove tag">×</span>
</span>
`).join('');
}
async function promptAddNoteTag() {
if (!notedogState.activeDbNote) return;
const tag = await showPromptDialog({
title: 'Add Note Tag',
subtitle: 'Attach a tag for filtering',
message: 'Enter tag name:',
placeholder: 'e.g. design, todo, reference',
confirmText: 'Add Tag'
});
if (!tag || !tag.trim()) return;
const cleanTag = tag.trim().replace(/^#/, '');
let tags = [];
try {
tags = typeof notedogState.activeDbNote.tags === 'string' ? JSON.parse(notedogState.activeDbNote.tags || '[]') : (notedogState.activeDbNote.tags || []);
} catch(e) { tags = []; }
if (!tags.includes(cleanTag)) {
tags.push(cleanTag);
notedogState.activeDbNote.tags = JSON.stringify(tags);
await saveActiveDatabaseNote(true);
renderDatabaseNoteTags(notedogState.activeDbNote);
showToast(`Added tag #${cleanTag}`, 'info');
}
}
async function removeNoteTag(tag) {
if (!notedogState.activeDbNote) return;
let tags = [];
try {
tags = typeof notedogState.activeDbNote.tags === 'string' ? JSON.parse(notedogState.activeDbNote.tags || '[]') : (notedogState.activeDbNote.tags || []);
} catch(e) { tags = []; }
tags = tags.filter(t => t !== tag);
notedogState.activeDbNote.tags = JSON.stringify(tags);
await saveActiveDatabaseNote(true);
renderDatabaseNoteTags(notedogState.activeDbNote);
}
async function toggleActiveNotePin() {
if (!notedogState.activeDbNote) return;
const newPinned = !notedogState.activeDbNote.is_pinned;
notedogState.activeDbNote.is_pinned = newPinned;
await saveActiveDatabaseNote(true);
const pinBtn = document.getElementById('btn-pin-note');
if (pinBtn) {
pinBtn.classList.toggle('active', newPinned);
pinBtn.style.color = newPinned ? 'var(--accent)' : 'var(--text-muted)';
}
renderDatabaseSidebar();
showToast(newPinned ? 'Note pinned to top' : 'Note unpinned', 'info');
}
async function openNoteDogColorPicker(e) {
if (!notedogState.activeDbNote) return;
const colors = [
{ label: 'Amber (Default)', value: '#f59e0b' },
{ label: 'Emerald Green', value: '#10b981' },
{ label: 'Sky Blue', value: '#38bdf8' },
{ label: 'Purple / Violet', value: '#a855f7' },
{ label: 'Rose / Pink', value: '#ec4899' },
{ label: 'Crimson Red', value: '#ef4444' },
{ label: 'None (Clear)', value: null }
];
let menu = 'Select note color accent:\n';
colors.forEach((c, idx) => {
menu += `${idx + 1}. ${c.label}\n`;
});
const choice = await showPromptDialog({
title: 'Note Color Accent',
subtitle: 'Categorize & Highlight Note',
message: `${menu}\nEnter number (1-7) or hex color code:`,
defaultValue: '1',
placeholder: '1-7 or #f59e0b',
confirmText: 'Set Color'
});
if (!choice) return;
const trimmed = choice.trim();
const idx = parseInt(trimmed, 10) - 1;
if (!isNaN(idx) && idx >= 0 && idx < colors.length) {
const sel = colors[idx];
setNoteColor(sel.value);
} else if (trimmed.startsWith('#') || trimmed.length >= 3) {
setNoteColor(trimmed);
}
}
async function setNoteColor(color) {
if (!notedogState.activeDbNote) return;
notedogState.activeDbNote.color = color;
await saveActiveDatabaseNote(true);
const colorIndicator = document.getElementById('notedog-color-indicator');
if (colorIndicator) {
colorIndicator.style.background = color || 'transparent';
colorIndicator.style.border = color ? `1px solid ${color}` : '1px solid rgba(255,255,255,0.25)';
}
renderDatabaseSidebar();
}
async function promptChangeNoteCategory() {
if (!notedogState.activeDbNote) return;
const newCat = await showPromptDialog({
title: 'Change Note Category',
subtitle: 'Move note to a different category',
message: 'Enter category name:',
defaultValue: notedogState.activeDbNote.category || 'General',
placeholder: 'e.g. General, Work, Personal, Projects',
confirmText: 'Move'
});
if (newCat === null) return;
const clean = newCat.trim() || 'General';
notedogState.activeDbNote.category = clean;
notedogState.activeCategory = clean;
await saveActiveDatabaseNote(true);
await loadDatabaseNotesHierarchy(notedogState.activeDbNote.id);
showToast(`Moved note to category "${clean}"`, 'success');
}
async function promptChangeNoteSection() {
if (!notedogState.activeDbNote) return;
const newSec = await showPromptDialog({
title: 'Change Note Section',
subtitle: 'Move note to a section within category',
message: 'Enter section name:',
defaultValue: notedogState.activeDbNote.section || 'Default',
placeholder: 'e.g. Default, Ideas, Snippets, Archive',
confirmText: 'Move'
});
if (newSec === null) return;
const clean = newSec.trim() || 'Default';
notedogState.activeDbNote.section = clean;
notedogState.activeDbSection = clean;
await saveActiveDatabaseNote(true);
await loadDatabaseNotesHierarchy(notedogState.activeDbNote.id);
showToast(`Moved note to section "${clean}"`, 'success');
}
async function promptCreateNotebookOrCategory() {
if (notedogState.storageMode === 'database') {
const cat = await showPromptDialog({
title: 'New Category',
subtitle: 'Create a new category for notes',
message: 'Enter new category name:',
placeholder: 'e.g. Personal, Research, Snippets',
confirmText: 'Create'
});
if (!cat || !cat.trim()) return;
const clean = cat.trim();
if (!notedogState.categories.includes(clean)) {
notedogState.categories.push(clean);
}
notedogState.activeCategory = clean;
notedogState.activeDbSection = 'Default';
renderDatabaseSidebar();
showToast(`Category "${clean}" created`, 'success');
} else {
promptCreateNotebook();
}
}
async function saveActiveDatabaseNote(isAutoSave = false) {
if (!notedogState.activeDbNote) return;
const textarea = document.getElementById('notedog-editor-textarea');
const titleInput = document.getElementById('notedog-active-title');
const saveStatus = document.getElementById('notedog-save-status');
const content = textarea ? textarea.value : notedogState.content;
const title = (titleInput ? titleInput.value : notedogState.activeDbNote.title).trim() || 'Untitled Note';
try {
const payload = {
title,
content,
category: notedogState.activeDbNote.category,
section: notedogState.activeDbNote.section,
tags: notedogState.activeDbNote.tags,
is_pinned: notedogState.activeDbNote.is_pinned,
is_archived: notedogState.activeDbNote.is_archived,
color: notedogState.activeDbNote.color ? notedogState.activeDbNote.color : null
};
const resp = await fetch(`/api/notes/${notedogState.activeDbNote.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify(payload)
});
if (resp.ok) {
const updated = await resp.json();
notedogState.activeDbNote = updated;
notedogState.isDirty = false;
if (saveStatus) {
saveStatus.textContent = 'Saved';
saveStatus.className = 'notedog-save-status';
}
if (!isAutoSave) {
showToast(`Saved note: ${updated.title}`, 'success');
}
} else {
const err = await resp.text();
if (!isAutoSave) showToast(`Save failed: ${err}`, 'error');
}
} catch (err) {
console.error('Save database note error:', err);
if (!isAutoSave) showToast('Failed to save note', 'error');
}
}
async function createDatabaseNote(title, initialContent, category, section) {
try {
const resp = await fetch('/api/notes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
title,
content: initialContent || `# ${title}\n\n`,
category: category || notedogState.activeCategory || 'General',
section: section || notedogState.activeDbSection || 'Default',
tags: '[]',
is_pinned: false,
is_encrypted: false,
color: null
})
});
if (resp.ok) {
const note = await resp.json();
await loadDatabaseNotesHierarchy(note.id);
showToast(`Created note "${title}"`, 'success');
} else {
showToast('Failed to create note: ' + await resp.text(), 'error');
}
} catch (err) {
showToast('Failed to create note: ' + err.message, 'error');
}
}
async function deleteActiveDatabaseNote() {
if (!notedogState.activeDbNote) return;
const noteTitle = notedogState.activeDbNote.title || 'Untitled Note';
const confirmed = await showConfirmDialog({
title: 'Delete Note',
subtitle: 'Remove note from database',
message: `Are you sure you want to delete note "${noteTitle}"?`,
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete Note'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/notes/${notedogState.activeDbNote.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
notedogState.activeDbNote = null;
await loadDatabaseNotesHierarchy();
showToast('Note deleted from database', 'info');
} else {
showToast('Failed to delete note', 'error');
}
} catch (err) {
showToast('Failed to delete note: ' + err.message, 'error');
}
}
// ---------------- ATTACHMENTS & MEDIA ENGINE ----------------
function triggerNoteAttachmentUpload() {
const fileInput = document.getElementById('notedog-attachment-file-input');
if (fileInput) {
fileInput.value = '';
fileInput.click();
}
}
async function handleNoteAttachmentFileSelected(files) {
if (!files || files.length === 0) return;
for (let i = 0; i < files.length; i++) {
await uploadNoteAttachment(files[i]);
}
}
async function uploadNoteAttachment(file) {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const noteId = (notedogState.storageMode === 'database' && notedogState.activeDbNote) ? notedogState.activeDbNote.id : null;
const url = noteId ? `/api/notes/${noteId}/attachments` : '/api/notes/attachments/upload';
try {
showToast(`Uploading attachment: ${file.name}...`, 'info');
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` },
body: formData
});
if (resp.ok) {
const att = await resp.json();
notedogState.activeAttachments.push(att);
renderAttachmentsTray();
// Automatically insert markdown tag into editor at cursor
insertAttachmentMarkdown(att);
showToast(`Attached: ${att.filename}`, 'success');
// Auto-save note so the markdown reference is persisted
if (notedogState.storageMode === 'database') {
saveActiveDatabaseNote(true);
} else {
saveActiveNoteDogNote(true);
}
} else {
const err = await resp.text();
showToast(`Upload failed: ${err}`, 'error');
}
} catch (err) {
showToast(`Upload failed: ${err.message}`, 'error');
}
}
function insertAttachmentMarkdown(att) {
const filename = att.filename;
const isImg = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(filename) || (att.mime_type && att.mime_type.startsWith('image/'));
const isAudio = /\.(mp3|wav|ogg|flac|m4a|aac)$/i.test(filename) || (att.mime_type && att.mime_type.startsWith('audio/'));
const url = `/api/notes/attachments/${att.id}/${encodeURIComponent(filename)}`;
let snippet = '';
if (isImg) {
snippet = `\n\n`;
} else if (isAudio) {
snippet = `\n<audio controls src="${url}"></audio>\n`;
} else {
snippet = `\n[📎 ${filename}](${url})\n`;
}
insertNoteDogMarkdown('', '', snippet);
}
function renderAttachmentsTray() {
const trayList = document.getElementById('notedog-att-tray-list');
const countBadge = document.getElementById('notedog-attachments-count-badge');
const countTray = document.getElementById('notedog-att-tray-count');
const tray = document.getElementById('notedog-attachments-tray');
const count = notedogState.activeAttachments.length;
if (countBadge) {
countBadge.textContent = count;
countBadge.style.display = count > 0 ? 'inline-flex' : 'none';
}
if (countTray) {
countTray.textContent = `${count} file${count === 1 ? '' : 's'}`;
}
if (trayList) {
if (count === 0) {
trayList.innerHTML = '<div style="font-size: 10px; color: var(--text-dim); padding: 4px;">No files attached to this note yet.</div>';
} else {
trayList.innerHTML = notedogState.activeAttachments.map(att => {
const isImg = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(att.filename) || (att.mime_type && att.mime_type.startsWith('image/'));
const isAudio = /\.(mp3|wav|ogg|flac|m4a|aac)$/i.test(att.filename) || (att.mime_type && att.mime_type.startsWith('audio/'));
const url = `/api/notes/attachments/${att.id}/${encodeURIComponent(att.filename)}`;
let thumbHtml = '';
if (isImg) {
thumbHtml = `<img src="${url}" class="notedog-att-thumb" alt="${escapeHtml(att.filename)}" />`;
} else if (isAudio) {
thumbHtml = `<div class="notedog-att-thumb" style="color: var(--accent); font-size: 14px;">🎵</div>`;
} else {
thumbHtml = `<div class="notedog-att-thumb" style="color: var(--accent); font-size: 14px;">📄</div>`;
}
return `
<div class="notedog-att-card">
${thumbHtml}
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(att.filename)}">${escapeHtml(att.filename)}</div>
<div style="font-size: 9px; color: var(--text-dim);">${formatFileSize(att.size_bytes || 0)}</div>
</div>
<button class="btn btn-icon btn-xs" onclick="insertAttachmentMarkdownById(${att.id})" title="Insert into Markdown" style="width: 20px; height: 20px; min-width: 20px; padding: 0;">
<i data-lucide="plus" style="width: 10px; height: 10px;"></i>
</button>
<a href="${url}" target="_blank" download="${escapeHtml(att.filename)}" class="btn btn-icon btn-xs" title="Download Attachment" style="width: 20px; height: 20px; min-width: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted);">
<i data-lucide="download" style="width: 10px; height: 10px;"></i>
</a>
<button class="btn btn-icon btn-xs btn-danger-hover" onclick="deleteNoteAttachment(${att.id})" title="Delete Attachment" style="width: 20px; height: 20px; min-width: 20px; padding: 0;">
<i data-lucide="trash-2" style="width: 10px; height: 10px;"></i>
</button>
</div>
`;
}).join('');
}
}
if (window.lucide) lucide.createIcons();
}
function insertAttachmentMarkdownById(id) {
const att = notedogState.activeAttachments.find(a => a.id === id);
if (att) insertAttachmentMarkdown(att);
}
async function deleteNoteAttachment(attachmentId) {
const confirmed = await showConfirmDialog({
title: 'Delete Attachment',
subtitle: 'Remove attached file from note',
message: 'Are you sure you want to delete this attachment?',
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/notes/attachments/${attachmentId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
notedogState.activeAttachments = notedogState.activeAttachments.filter(a => a.id !== attachmentId);
renderAttachmentsTray();
showToast('Attachment deleted', 'info');
}
} catch (err) {
showToast('Failed to delete attachment', 'error');
}
}
function toggleNoteAttachmentsTray() {
const tray = document.getElementById('notedog-attachments-tray');
if (!tray) return;
notedogState.attachmentsTrayOpen = (tray.style.display === 'none');
tray.style.display = notedogState.attachmentsTrayOpen ? 'flex' : 'none';
const toggleBtn = document.getElementById('btn-notedog-attachments-toggle');
if (toggleBtn) toggleBtn.classList.toggle('active', notedogState.attachmentsTrayOpen);
}
function initNoteDogEditorPasteAndDrop() {
if (notedogState.pasteDropInitialized) return;
notedogState.pasteDropInitialized = true;
const textarea = document.getElementById('notedog-editor-textarea');
const dropzone = document.getElementById('notedog-att-dropzone');
// Clipboard screenshot paste (Ctrl+V)
if (textarea) {
textarea.addEventListener('paste', async (e) => {
const clipboardData = e.clipboardData || window.clipboardData;
if (!clipboardData || !clipboardData.items) return;
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const screenshotFile = new File([file], `screenshot_${ts}.png`, { type: file.type || 'image/png' });
await uploadNoteAttachment(screenshotFile);
}
}
}
});
}
// Drag & drop files on textarea and dropzone
[textarea, dropzone].forEach(el => {
if (!el) return;
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
el.classList.add('dragover');
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove('dragover');
});
el.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove('dragover');
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await handleNoteAttachmentFileSelected(e.dataTransfer.files);
}
});
});
}
async function openNoteDogMigrationMenu(e) {
const choices = [
'1. 📤 Export all Database Notes to ~/Notes (Markdown Files)',
'2. 📥 Import ~/Notes Markdown files into Database Notes'
];
const choice = await showPromptDialog({
title: 'Database Notes Migration Center',
subtitle: 'Import / Export Markdown Files',
message: `${choices.join('\n')}\n\nEnter choice (1 or 2):`,
defaultValue: '1',
placeholder: '1 or 2',
confirmText: 'Run Migration'
});
if (choice === '1') {
executeNotesExport();
} else if (choice === '2') {
executeNotesImport();
}
}
async function executeNotesExport() {
try {
showToast('Exporting database notes to ~/Notes...', 'info');
const resp = await fetch('/api/notes/migrate/export', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
showToast(`Exported ${data.exported_count} notes to ${data.target_directory}`, 'success');
} else {
showToast('Export failed: ' + await resp.text(), 'error');
}
} catch (err) {
showToast('Export failed: ' + err.message, 'error');
}
}
async function executeNotesImport() {
try {
showToast('Importing ~/Notes into database...', 'info');
const resp = await fetch('/api/notes/migrate/import', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
showToast(`Imported ${data.imported_count} notes into Database`, 'success');
if (notedogState.storageMode === 'database') {
loadDatabaseNotesHierarchy();
}
} else {
showToast('Import failed: ' + await resp.text(), 'error');
}
} catch (err) {
showToast('Import failed: ' + err.message, 'error');
}
}
// ---------------- FILESYSTEM NOTES ENGINE ----------------
async function loadNoteDogHierarchy(targetNotePath) {
try {
const customFolder = notedogState.customFolder || localStorage.getItem('cd_notedog_folder');
const url = customFolder ? `/api/tools/notedog/info?folder=${encodeURIComponent(customFolder)}` : '/api/tools/notedog/info';
const resp = await fetch(url, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) return;
const data = await resp.json();
notedogState.rootFolder = data.root_folder;
notedogState.notebooks = data.notebooks || [];
// Resolve initial active notebook / section / note
if (targetNotePath) {
// Find matching note
for (const nb of notedogState.notebooks) {
for (const sec of nb.sections) {
for (const n of sec.notes) {
if (n.path === targetNotePath || n.relative_path === targetNotePath) {
notedogState.activeNotebook = nb.name;
notedogState.activeSection = sec.name;
notedogState.activeNote = n;
break;
}
}
}
}
}
if (!notedogState.activeNotebook && notedogState.notebooks.length > 0) {
notedogState.activeNotebook = notedogState.notebooks[0].name;
}
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook);
if (currentNb && currentNb.sections.length > 0) {
if (!notedogState.activeSection || !currentNb.sections.some(s => s.name === notedogState.activeSection)) {
notedogState.activeSection = currentNb.sections[0].name;
}
}
const currentSec = currentNb?.sections.find(s => s.name === notedogState.activeSection);
if (currentSec && currentSec.notes.length > 0) {
if (!notedogState.activeNote || !currentSec.notes.some(n => n.path === notedogState.activeNote.path)) {
notedogState.activeNote = currentSec.notes[0];
}
}
renderNoteDogSidebar();
if (notedogState.activeNote) {
loadNoteDogNoteContent(notedogState.activeNote);
} else {
// Blank state
const titleInput = document.getElementById('notedog-active-title');
if (titleInput) titleInput.value = 'No notes yet';
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = '';
const preview = document.getElementById('notedog-preview-content');
if (preview) preview.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 40px;">Click <b>+ Note</b> to create your first note!</div>';
}
} catch (err) {
console.error('NoteDog load hierarchy failed:', err);
}
}
function renderNoteDogSidebar() {
const nbList = document.getElementById('notedog-notebooks-list');
const secList = document.getElementById('notedog-sections-list');
const noteList = document.getElementById('notedog-notes-list');
const breadcrumb = document.getElementById('notedog-current-breadcrumb');
const cleanActiveNb = (notedogState.activeNotebook || '').replace(/^[^\w\s&]+/, '').trim();
const cleanActiveSec = (notedogState.activeSection || '').replace(/^[^\w\s&]+/, '').trim();
if (breadcrumb) {
breadcrumb.textContent = `${cleanActiveNb || 'Notes'} / ${cleanActiveSec || 'General'}`;
}
// 1. Notebooks list
if (nbList) {
if (notedogState.notebooks.length === 0) {
nbList.innerHTML = '<div style="padding: 6px; font-size: 10px; color: var(--text-dim);">No notebooks</div>';
} else {
nbList.innerHTML = notedogState.notebooks.map(nb => {
const cleanNb = nb.name.replace(/^[^\w\s&]+/, '').trim();
const isManualNb = nb.path?.startsWith('manual://') || cleanNb === 'Manuals';
const totalNotes = nb.sections.reduce((acc, s) => acc + s.notes.length, 0);
const isActive = cleanNb === cleanActiveNb || nb.name === notedogState.activeNotebook;
const icon = nb.is_encrypted ? '🔒' : (isManualNb ? '📖' : '📚');
const encLabel = nb.is_encrypted ? ' [Encrypted Notebook]' : '';
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectNoteDogNotebook('${escapeHtml(nb.name)}')" title="${escapeHtml(cleanNb)}${encLabel} (${totalNotes} notes)">
<span>${icon}</span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${escapeHtml(cleanNb)}</span>
<span class="notedog-item-count">${totalNotes}</span>
</div>
`;
}).join('');
}
}
// 2. Sections list
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook || nb.name.replace(/^[^\w\s&]+/, '').trim() === cleanActiveNb);
if (secList) {
if (!currentNb || currentNb.sections.length === 0) {
secList.innerHTML = '<div style="padding: 6px; font-size: 10px; color: var(--text-dim);">No sections</div>';
} else {
const isManualNb = currentNb.path?.startsWith('manual://') || currentNb.name.replace(/^[^\w\s&]+/, '').trim() === 'Manuals';
secList.innerHTML = currentNb.sections.map(sec => {
const cleanSec = sec.name.replace(/^[^\w\s&]+/, '').trim();
const isActive = cleanSec === cleanActiveSec || sec.name === notedogState.activeSection;
const icon = sec.is_encrypted ? '🔒' : (isManualNb ? getManualSectionIcon(sec.name) : '📂');
const encLabel = sec.is_encrypted ? ' [Encrypted Section]' : '';
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectNoteDogSection('${escapeHtml(sec.name)}')" title="${escapeHtml(cleanSec)}${encLabel} (${sec.notes.length} notes)">
<span>${icon}</span>
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${escapeHtml(cleanSec)}</span>
<span class="notedog-item-count">${sec.notes.length}</span>
</div>
`;
}).join('');
}
}
// 3. Notes list (supports search filter across all or active section)
if (noteList) {
let notesToRender = [];
if (notedogState.searchQuery.trim()) {
const q = notedogState.searchQuery.toLowerCase();
notedogState.notebooks.forEach(nb => {
nb.sections.forEach(sec => {
sec.notes.forEach(n => {
if (n.name.toLowerCase().includes(q) || n.filename.toLowerCase().includes(q)) {
notesToRender.push({ ...n, nbName: nb.name, secName: sec.name });
}
});
});
});
} else {
const currentSec = currentNb?.sections.find(s => s.name === notedogState.activeSection || s.name.replace(/^[^\w\s&]+/, '').trim() === cleanActiveSec);
notesToRender = currentSec ? currentSec.notes : [];
}
if (notesToRender.length === 0) {
noteList.innerHTML = `<div style="padding: 10px; font-size: 11px; color: var(--text-dim); text-align: center;">${notedogState.searchQuery ? 'No matching notes' : 'No notes in section'}</div>`;
} else {
noteList.innerHTML = notesToRender.map(note => {
const isActive = notedogState.activeNote && (notedogState.activeNote.path === note.path);
const icon = note.is_encrypted ? '🔒' : '📄';
const cleanName = note.name.replace(/^[^\w\s&]+/, '').trim();
const subtext = note.nbName ? `${note.nbName.replace(/^[^\w\s&]+/, '').trim()}/${note.secName.replace(/^[^\w\s&]+/, '').trim()}` : '';
return `
<div class="notedog-item ${isActive ? 'active' : ''}" onclick="selectNoteDogNoteByPath('${escapeHtml(note.path)}')" title="${escapeHtml(note.filename)}">
<span>${icon}</span>
<div style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
<div>${escapeHtml(cleanName)}</div>
${subtext ? `<div style="font-size: 9px; color: var(--text-dim);">${escapeHtml(subtext)}</div>` : ''}
</div>
</div>
`;
}).join('');
}
}
if (window.lucide) lucide.createIcons();
}
function selectNoteDogNotebook(nbName) {
const cleanNb = (nbName || '').replace(/^[^\w\s&]+/, '').trim();
notedogState.activeNotebook = cleanNb;
const currentNb = notedogState.notebooks.find(nb => nb.name === nbName || nb.name.replace(/^[^\w\s&]+/, '').trim() === cleanNb);
if (currentNb && currentNb.sections.length > 0) {
notedogState.activeSection = currentNb.sections[0].name;
if (currentNb.sections[0].notes.length > 0) {
return selectNoteDogNote({ ...currentNb.sections[0].notes[0], nbName: currentNb.name, secName: currentNb.sections[0].name });
} else {
notedogState.activeNote = null;
notedogState.content = '';
}
} else {
notedogState.activeSection = '';
notedogState.activeNote = null;
notedogState.content = '';
}
renderNoteDogSidebar();
return Promise.resolve();
}
function selectNoteDogSection(secName) {
const cleanActiveNb = (notedogState.activeNotebook || '').replace(/^[^\w\s&]+/, '').trim();
const cleanSec = (secName || '').replace(/^[^\w\s&]+/, '').trim();
notedogState.activeSection = cleanSec;
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook || nb.name.replace(/^[^\w\s&]+/, '').trim() === cleanActiveNb);
const currentSec = currentNb?.sections.find(s => s.name === secName || s.name.replace(/^[^\w\s&]+/, '').trim() === cleanSec);
if (currentSec && currentSec.notes.length > 0) {
return selectNoteDogNote({ ...currentSec.notes[0], nbName: currentNb.name, secName: currentSec.name });
} else {
notedogState.activeNote = null;
notedogState.content = '';
}
renderNoteDogSidebar();
return Promise.resolve();
}
function selectNoteDogNoteByPath(path) {
if (!path) return Promise.resolve();
let found = null;
const nbs = [...(notedogState.notebooks || [])];
const manualNb = getNoteDogManualsNotebook();
if (!nbs.some(nb => nb.path?.startsWith('manual://') || nb.name.replace(/^[^\w\s&]+/, '').trim() === 'Manuals')) {
nbs.push(manualNb);
}
for (const nb of nbs) {
for (const sec of nb.sections) {
const n = sec.notes.find(it => it.path === path || it.relative_path === path || it.filename === path);
if (n) {
found = { ...n, nbName: nb.name.replace(/^[^\w\s&]+/, '').trim(), secName: sec.name.replace(/^[^\w\s&]+/, '').trim() };
break;
}
}
if (found) break;
}
if (found) {
if (notedogState.storageMode === 'database') {
notedogState.activeCategory = 'Manuals';
notedogState.activeDbSection = found.secName;
renderDatabaseSidebar();
}
return selectNoteDogNote(found);
}
return Promise.resolve();
}
function selectNoteDogNote(note) {
if (notedogState.isDirty && notedogState.activeNote) {
saveActiveNoteDogNote();
}
notedogState.activeNote = note;
if (note.nbName && note.secName) {
notedogState.activeNotebook = note.nbName;
notedogState.activeSection = note.secName;
}
renderNoteDogSidebar();
const loadPromise = loadNoteDogNoteContent(note);
// On Phone, Foldable and Tablet viewports (<= 1024px), auto-collapse the sliding drawer on note selection
if (window.innerWidth <= 1024) {
closeNoteDogDrawer();
}
return loadPromise;
}
async function loadNoteDogNoteContent(note) {
const titleInput = document.getElementById('notedog-active-title');
const textarea = document.getElementById('notedog-editor-textarea');
const preview = document.getElementById('notedog-preview-content');
const saveStatus = document.getElementById('notedog-save-status');
const unlockCard = document.getElementById('notedog-unlock-card');
const lockIcon = document.getElementById('icon-encrypt-note');
const saveBtn = document.getElementById('btn-save-notedog');
const deleteBtn = document.getElementById('btn-delete-notedog');
const tmplBtn = document.getElementById('btn-template-notedog');
const encBtn = document.getElementById('btn-encrypt-note') || lockIcon?.closest('button');
const isManual = note.path?.startsWith('manual://') || note.is_manual;
if (titleInput) {
titleInput.value = note.name;
titleInput.readOnly = isManual;
}
if (isManual) {
if (saveStatus) {
saveStatus.textContent = '📖 Manual (Read-Only)';
saveStatus.className = 'notedog-save-status manual-status';
saveStatus.style.color = 'var(--accent)';
saveStatus.style.background = 'rgba(245, 158, 11, 0.12)';
saveStatus.style.border = '1px solid rgba(245, 158, 11, 0.25)';
saveStatus.style.borderRadius = '4px';
saveStatus.style.padding = '1px 6px';
}
if (saveBtn) saveBtn.style.display = 'none';
if (deleteBtn) deleteBtn.style.display = 'none';
if (tmplBtn) tmplBtn.style.display = 'none';
if (encBtn) encBtn.style.display = 'none';
if (textarea) textarea.readOnly = true;
} else {
if (saveStatus) {
saveStatus.textContent = 'Saved';
saveStatus.className = 'notedog-save-status';
saveStatus.style.color = '';
saveStatus.style.background = '';
saveStatus.style.border = '';
saveStatus.style.borderRadius = '';
saveStatus.style.padding = '';
}
if (saveBtn) saveBtn.style.display = '';
if (deleteBtn) deleteBtn.style.display = '';
if (tmplBtn) tmplBtn.style.display = '';
if (encBtn) encBtn.style.display = '';
if (textarea) textarea.readOnly = false;
}
const isEnc = note.is_encrypted || note.filename?.endsWith('.md.enc');
if (lockIcon) {
lockIcon.setAttribute('data-lucide', isEnc ? 'lock' : 'unlock');
lockIcon.style.color = isEnc ? 'var(--accent)' : 'inherit';
if (window.lucide) lucide.createIcons();
}
const syncDockedInstances = (content) => {
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'notedog') {
const dTa = document.getElementById(`docked-notedog-textarea-${idx}`);
if (dTa) dTa.value = content;
const dTitle = document.getElementById(`docked-notedog-title-${idx}`);
if (dTitle) dTitle.textContent = note.name || 'NoteDog';
renderDockedNoteDogPreview(idx, content);
const noteSel = document.getElementById(`docked-notedog-note-select-${idx}`);
if (noteSel && noteSel.value !== note.path) noteSel.value = note.path;
}
});
};
if (isEnc) {
const cached = notedogState.cachedPassphrases[note.path] || notedogState.cachedPassphrases[notedogState.activeSection] || notedogState.cachedPassphrases['__global'];
if (cached) {
try {
const resp = await fetch('/api/tools/notedog/decrypt', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: note.path, passphrase: cached })
});
if (resp.ok) {
const data = await resp.json();
notedogState.content = data.content || '';
notedogState.isDirty = false;
notedogState.unlockedNotes[note.path] = true;
if (unlockCard) unlockCard.style.display = 'none';
if (textarea) textarea.value = notedogState.content;
syncNoteDogGutter();
renderNoteDogPreview(notedogState.content);
syncDockedInstances(notedogState.content);
return;
}
} catch (e) {
console.warn('Cached passphrase decrypt failed:', e);
}
}
// Show in-workspace unlock card
if (unlockCard) {
unlockCard.style.display = 'flex';
const unlockTitle = document.getElementById('notedog-unlock-title');
if (unlockTitle) unlockTitle.textContent = `${note.name} (Encrypted)`;
const passInput = document.getElementById('notedog-unlock-pass');
if (passInput) {
passInput.value = '';
setTimeout(() => passInput.focus(), 50);
}
const errEl = document.getElementById('notedog-unlock-error');
if (errEl) errEl.style.display = 'none';
}
if (textarea) textarea.value = '';
if (preview) preview.innerHTML = '';
syncDockedInstances('');
} else {
// Plain note
if (unlockCard) unlockCard.style.display = 'none';
try {
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(note.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
const content = data.content || '';
notedogState.content = content;
notedogState.isDirty = false;
if (textarea) textarea.value = content;
syncNoteDogGutter();
renderNoteDogPreview(content);
syncDockedInstances(content);
} else {
const errText = await resp.text();
const errMsg = `Failed to load note content (${resp.status}): ${errText || 'Access Denied'}`;
if (textarea) textarea.value = errMsg;
if (preview) preview.innerHTML = `<div style="padding: 16px; color: var(--danger); font-family: var(--font-mono); font-size: 12px;">⚠️ ${escapeHtml(errMsg)}</div>`;
syncDockedInstances(errMsg);
}
} catch (err) {
console.error('NoteDog read note failed:', err);
if (textarea) textarea.value = `Failed to load note: ${err}`;
syncDockedInstances(`Failed to load note: ${err}`);
}
}
}
async function executeUnlockNoteDogNote() {
if (!notedogState.activeNote) return;
const passInput = document.getElementById('notedog-unlock-pass');
const pass = passInput ? passInput.value : '';
const remember = document.getElementById('notedog-unlock-remember')?.checked !== false;
const errEl = document.getElementById('notedog-unlock-error');
const unlockCard = document.getElementById('notedog-unlock-card');
const textarea = document.getElementById('notedog-editor-textarea');
if (!pass) {
if (errEl) {
errEl.textContent = 'Please enter a passphrase';
errEl.style.display = 'block';
}
return;
}
try {
const resp = await fetch('/api/tools/notedog/decrypt', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: notedogState.activeNote.path, passphrase: pass })
});
if (resp.ok) {
const data = await resp.json();
if (remember) {
notedogState.cachedPassphrases[notedogState.activeNote.path] = pass;
if (notedogState.activeSection) notedogState.cachedPassphrases[notedogState.activeSection] = pass;
notedogState.cachedPassphrases['__global'] = pass;
}
notedogState.content = data.content || '';
notedogState.isDirty = false;
notedogState.unlockedNotes[notedogState.activeNote.path] = true;
if (unlockCard) unlockCard.style.display = 'none';
if (textarea) textarea.value = notedogState.content;
syncNoteDogGutter();
renderNoteDogPreview(notedogState.content);
showToast('Note unlocked', 'success');
} else {
const err = await resp.text();
if (errEl) {
errEl.textContent = 'Decryption failed: ' + (err || 'Incorrect passphrase');
errEl.style.display = 'block';
}
}
} catch (err) {
if (errEl) {
errEl.textContent = 'Error: ' + err.message;
errEl.style.display = 'block';
}
}
}
function handleNoteDogInput() {
const textarea = document.getElementById('notedog-editor-textarea');
const saveStatus = document.getElementById('notedog-save-status');
if (!textarea) return;
notedogState.content = textarea.value;
notedogState.isDirty = true;
if (saveStatus) {
saveStatus.textContent = 'Unsaved *';
saveStatus.className = 'notedog-save-status unsaved';
}
syncNoteDogGutter();
renderNoteDogPreview(notedogState.content);
// Sync docked NoteDog instances across all panes
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'notedog') {
const dTa = document.getElementById(`docked-notedog-textarea-${idx}`);
if (dTa) dTa.value = notedogState.content;
renderDockedNoteDogPreview(idx, notedogState.content);
}
});
// Debounced auto-save after 2.5s of typing pause
if (notedogState.autoSaveTimer) clearTimeout(notedogState.autoSaveTimer);
notedogState.autoSaveTimer = setTimeout(() => {
if (notedogState.isDirty) {
if (notedogState.storageMode === 'database' && notedogState.activeDbNote) {
saveActiveDatabaseNote(true);
} else if (notedogState.activeNote) {
saveActiveNoteDogNote(true);
}
}
}, 2500);
}
async function saveActiveNoteDogNote(isAutoSave = false) {
if (notedogState.storageMode === 'database') {
return saveActiveDatabaseNote(isAutoSave);
}
if (!notedogState.activeNote) return;
if (notedogState.activeNote.path?.startsWith('manual://') || notedogState.activeNote.is_manual) {
if (!isAutoSave) {
showToast('Built-in repository user & QA testing manuals are read-only', 'info');
}
return;
}
const textarea = document.getElementById('notedog-editor-textarea');
const saveStatus = document.getElementById('notedog-save-status');
const content = textarea ? textarea.value : notedogState.content;
const isEnc = notedogState.activeNote.is_encrypted || notedogState.activeNote.filename?.endsWith('.md.enc');
try {
let resp;
if (isEnc) {
const pass = notedogState.cachedPassphrases[notedogState.activeNote.path] || notedogState.cachedPassphrases[notedogState.activeSection] || notedogState.cachedPassphrases['__global'] || 'notedog';
resp = await fetch('/api/tools/notedog/encrypt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: notedogState.activeNote.path, content, passphrase: pass })
});
} else {
resp = await fetch('/api/fs/write', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: notedogState.activeNote.path, content })
});
}
if (resp.ok) {
notedogState.isDirty = false;
if (saveStatus) {
saveStatus.textContent = 'Saved';
saveStatus.className = 'notedog-save-status';
}
// Record snapshot revision in .notedog_versions
fetch('/api/tools/notedog/version/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: notedogState.activeNote.path })
}).catch(() => {});
if (!isAutoSave) {
showToast(`Saved note: ${notedogState.activeNote.name}`, 'success');
}
} else {
const errText = await resp.text();
if (!isAutoSave) showToast(`Failed to save note: ${errText}`, 'error');
}
} catch (err) {
console.error('NoteDog save error:', err);
if (!isAutoSave) showToast('Failed to save note', 'error');
}
}
function syncNoteDogGutter() {
const textarea = document.getElementById('notedog-editor-textarea');
const gutter = document.getElementById('notedog-editor-gutter');
if (!textarea || !gutter) return;
const lines = (textarea.value.match(/\n/g) || []).length + 1;
let gutterHtml = '';
for (let i = 1; i <= lines; i++) {
gutterHtml += `${i}\n`;
}
gutter.textContent = gutterHtml;
gutter.scrollTop = textarea.scrollTop;
}
function handleNoteDogKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveActiveNoteDogNote();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'e' || e.key === 'E')) {
e.preventDefault();
toggleCurrentNoteEncryption();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
insertNoteDogMarkdown('**', '**', 'bold text');
} else if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault();
insertNoteDogMarkdown('*', '*', 'italic text');
} else if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
handleNoteDogInput();
}
}
async function handleNoteDogTitleChange(newTitle) {
const cleanTitle = (newTitle || '').trim();
if (!cleanTitle) return;
if (notedogState.storageMode === 'database') {
if (!notedogState.activeDbNote) return;
if (cleanTitle === notedogState.activeDbNote.title) return;
notedogState.activeDbNote.title = cleanTitle;
await saveActiveDatabaseNote(true);
renderDatabaseSidebar();
showToast(`Renamed note to "${cleanTitle}"`, 'info');
return;
}
if (!notedogState.activeNote) return;
if (cleanTitle === notedogState.activeNote.name) return;
const oldPath = notedogState.activeNote.path;
const dir = oldPath.substring(0, oldPath.lastIndexOf('/'));
const ext = oldPath.endsWith('.md.enc') ? '.md.enc' : '.md';
const newFilename = `${cleanTitle}${ext}`;
const newPath = `${dir}/${newFilename}`;
try {
const resp = await fetch('/api/fs/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: oldPath, destination: newPath })
});
if (resp.ok) {
notedogState.activeNote.name = cleanTitle;
notedogState.activeNote.filename = newFilename;
notedogState.activeNote.path = newPath;
loadNoteDogHierarchy(newPath);
showToast(`Renamed note to "${cleanTitle}"`, 'info');
}
} catch (err) {
console.error('NoteDog rename note failed:', err);
}
}
function handleNoteDogSearch(query) {
notedogState.searchQuery = query || '';
if (notedogState.storageMode === 'database') {
loadDatabaseNotesHierarchy();
} else {
renderNoteDogSidebar();
}
}
function setNoteDogViewMode(mode) {
notedogState.viewMode = mode;
const wrapper = document.getElementById('notedog-panes-wrapper');
if (wrapper) {
wrapper.className = `notedog-panes-wrapper ${mode}-mode`;
}
document.querySelectorAll('.docked-notedog-box .notedog-panes-wrapper').forEach(wrap => {
wrap.className = `notedog-panes-wrapper ${mode}-mode`;
const editPane = wrap.querySelector('.notedog-editor-pane');
const prevPane = wrap.querySelector('.notedog-preview-pane');
if (editPane) {
editPane.style.display = mode === 'preview' ? 'none' : 'flex';
editPane.style.width = mode === 'edit' ? '100%' : '50%';
}
if (prevPane) {
prevPane.style.display = mode === 'edit' ? 'none' : 'flex';
prevPane.style.width = mode === 'preview' ? '100%' : '50%';
}
});
['edit', 'split', 'preview'].forEach(m => {
const btn = document.getElementById(`notedog-mode-${m}`);
if (btn) btn.classList.toggle('active', m === mode);
App.panes.forEach((_, idx) => {
const dockedBtn = document.getElementById(`docked-notedog-mode-${m}-${idx}`);
if (dockedBtn) dockedBtn.classList.toggle('active', m === mode);
});
});
}
function generateNoteDogMarkdownHtml(text) {
if (!text || !text.trim()) {
return '<div style="color: var(--text-dim); font-style: italic; padding: 6px;">Empty note</div>';
}
// Rewrite relative paths for ../assets/ -> assets/
let transformed = (text || '')
.replace(/(src|href)=["']\.\.\/assets\//g, '$1="assets/')
.replace(/!\[(.*?)\]\(\.\.\/assets\//g, ';
if (typeof renderDocViewerMarkdownPreview === 'function') {
return renderDocViewerMarkdownPreview(transformed);
}
if (typeof marked !== 'undefined' && marked.parse) {
try {
marked.setOptions({ gfm: true, breaks: true, headerIds: true, mangle: false });
return marked.parse(transformed);
} catch (e) {
console.warn('marked.parse error in notes:', e);
}
}
const lines = transformed.split('\n');
let html = '';
let inCodeBlock = false;
let codeBlockContent = '';
let codeBlockLang = '';
lines.forEach((line, idx) => {
// Code block check
if (line.trim().startsWith('```')) {
if (!inCodeBlock) {
inCodeBlock = true;
codeBlockLang = line.trim().replace(/^```/, '').trim();
codeBlockContent = '';
} else {
inCodeBlock = false;
if (codeBlockLang === 'mermaid') {
html += `<div class="notedog-mermaid-box"><pre><code class="language-mermaid">${escapeHtml(codeBlockContent)}</code></pre></div>`;
} else {
html += `<pre><code>${escapeHtml(codeBlockContent)}</code></pre>`;
}
}
return;
}
if (inCodeBlock) {
codeBlockContent += (codeBlockContent ? '\n' : '') + line;
return;
}
// 1. Interactive Checkboxes: - [ ] Task or - [x] Task
const taskMatch = line.match(/^(\s*)-\s*\[([ xX])\]\s*(.*)$/);
if (taskMatch) {
const isChecked = taskMatch[2].toLowerCase() === 'x';
const taskText = formatNoteDogInlineMarkdown(taskMatch[3]);
html += `
<div class="notedog-task-item" onclick="toggleNoteDogTaskCheckbox(${idx})">
<input type="checkbox" class="notedog-task-checkbox" ${isChecked ? 'checked' : ''} onclick="event.stopPropagation(); toggleNoteDogTaskCheckbox(${idx})" />
<span class="notedog-task-text ${isChecked ? 'completed' : ''}">${taskText}</span>
</div>
`;
return;
}
// 2. Headers
if (line.startsWith('### ')) {
html += `<h3>${formatNoteDogInlineMarkdown(line.slice(4))}</h3>`;
return;
}
if (line.startsWith('## ')) {
html += `<h2>${formatNoteDogInlineMarkdown(line.slice(3))}</h2>`;
return;
}
if (line.startsWith('# ')) {
html += `<h1>${formatNoteDogInlineMarkdown(line.slice(2))}</h1>`;
return;
}
// 3. Horizontal Rule
if (line.trim() === '---' || line.trim() === '***' || line.trim() === '___') {
html += '<hr>';
return;
}
// 4. Blockquotes
if (line.startsWith('> ')) {
html += `<blockquote>${formatNoteDogInlineMarkdown(line.slice(2))}</blockquote>`;
return;
}
// 5. Unordered List
if (line.startsWith('- ') || line.startsWith('* ')) {
html += `<ul><li>${formatNoteDogInlineMarkdown(line.slice(2))}</li></ul>`;
return;
}
// 6. Regular paragraphs
if (line.trim() === '') {
html += '<br>';
} else {
html += `<p>${formatNoteDogInlineMarkdown(line)}</p>`;
}
});
return html;
}
function renderNoteDogPreview(text) {
const preview = document.getElementById('notedog-preview-content');
if (preview) {
preview.innerHTML = generateNoteDogMarkdownHtml(text);
if (typeof postProcessMarkdownContainer === 'function') {
postProcessMarkdownContainer(preview);
}
if (window.lucide) lucide.createIcons();
if (window.mermaid) {
try {
mermaid.run({ nodes: preview.querySelectorAll('.mermaid') });
} catch (e) {}
}
}
}
function formatNoteDogInlineMarkdown(str) {
if (!str) return '';
let res = escapeHtml(str);
// Universal HTML color spans: <span style="color:#...">text</span> and <font color="...">text</font>
res = res.replace(/<span style="color:\s*([^&]+)">(.*?)<\/span>/gi, '<span style="color: $1">$2</span>');
res = res.replace(/<font color="([^&]+)">(.*?)<\/font>/gi, '<span style="color: $1">$2</span>');
// Shorthand color tags: {[#color]text}
res = res.replace(/\{\[([#a-zA-Z0-9]+)\](.*?)\}/g, '<span style="color: $1">$2</span>');
// Images & Media: 
res = res.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, url) => {
const cleanUrl = url.trim();
const lower = cleanUrl.toLowerCase();
if (lower.endsWith('.mp3') || lower.endsWith('.wav') || lower.endsWith('.ogg') || lower.endsWith('.m4a') || lower.endsWith('.aac') || lower.endsWith('.flac')) {
return `<audio controls src="${cleanUrl}" style="max-width:100%; margin:6px 0; display:block;"></audio>`;
}
if (lower.endsWith('.mp4') || lower.endsWith('.webm') || lower.endsWith('.mov') || lower.endsWith('.ogv')) {
return `<video controls src="${cleanUrl}" style="max-width:100%; border-radius:6px; margin:6px 0; display:block;"></video>`;
}
return `<img src="${cleanUrl}" alt="${alt}" class="notedog-rendered-img" style="max-width:100%; border-radius:6px; margin:6px 0; display:block;" onerror="this.style.display='none'" />`;
});
// Markdown links: [text](url)
res = res.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--accent); text-decoration:underline;">$1</a>');
// Bold **text**
res = res.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Italic *text*
res = res.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Strikethrough ~~text~~
res = res.replace(/~~(.*?)~~/g, '<del>$1</del>');
// Inline code `code`
res = res.replace(/`([^`]+)`/g, '<code>$1</code>');
return res;
}
function toggleNoteDogTaskCheckbox(lineIndex) {
const content = notedogState.content || '';
const lines = content.split('\n');
if (lineIndex < 0 || lineIndex >= lines.length) return;
const line = lines[lineIndex];
if (line.match(/^(\s*)-\s*\[ \]\s*(.*)$/)) {
lines[lineIndex] = line.replace(/^(\s*)-\s*\[ \]/, '$1- [x]');
} else if (line.match(/^(\s*)-\s*\[[xX]\]\s*(.*)$/)) {
lines[lineIndex] = line.replace(/^(\s*)-\s*\[[xX]\]/, '$1- [ ]');
}
const updated = lines.join('\n');
notedogState.content = updated;
notedogState.isDirty = true;
const mainTa = document.getElementById('notedog-editor-textarea');
if (mainTa) mainTa.value = updated;
renderNoteDogPreview(updated);
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'notedog') {
const dTa = document.getElementById(`docked-notedog-textarea-${idx}`);
if (dTa) dTa.value = updated;
renderDockedNoteDogPreview(idx, updated);
}
});
saveActiveNoteDogNote(true);
}
function insertNoteDogMarkdown(prefix, suffix, defaultText) {
const textarea = document.getElementById('notedog-editor-textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end) || defaultText;
const replacement = `${prefix}${selectedText}${suffix}`;
textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end);
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selectedText.length;
textarea.focus();
handleNoteDogInput();
}
function insertNoteDogMermaid() {
const template = `\n\`\`\`mermaid\ngraph TD\n A[Start Process] --> B{Condition}\n B -->|Yes| C[Success]\n B -->|No| D[Retry]\n\`\`\`\n`;
insertNoteDogMarkdown('', '', template);
}
async function insertNoteDogColorTag() {
const color = await showPromptDialog({
title: 'Insert Colored Text',
subtitle: 'HTML/Markdown Color Span',
message: 'Enter hex color or CSS color name:',
defaultValue: '#f59e0b',
placeholder: 'e.g. #f59e0b, gold, #38bdf8',
confirmText: 'Insert'
});
if (color) {
insertNoteDogMarkdown(`<span style="color:${color.trim()}">`, '</span>', 'colored text');
}
}
function insertNoteDogTimestamp() {
const now = new Date();
const ts = now.toISOString().replace('T', ' ').substring(0, 16);
insertNoteDogMarkdown('', '', `**${ts}** `);
}
async function promptCreateNotebook() {
const name = await showPromptDialog({
title: 'New Notebook',
subtitle: 'Create a new notebook folder in Notes',
message: 'Enter new Notebook name:',
placeholder: 'e.g. Personal, Projects, Ideas',
confirmText: 'Create'
});
if (!name || !name.trim()) return;
const nbName = name.trim();
try {
const root = notedogState.rootFolder || '~/Notes';
const nbPath = `${root}/${nbName}/General`;
await fetch('/api/fs/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: nbPath })
});
await loadNoteDogHierarchy();
selectNoteDogNotebook(nbName);
showToast(`Created Notebook "${nbName}"`, 'success');
} catch (err) {
showToast('Failed to create notebook', 'error');
}
}
async function promptCreateSection() {
if (notedogState.storageMode === 'database') {
const name = await showPromptDialog({
title: 'New Section',
subtitle: `Add section to category "${notedogState.activeCategory || 'General'}"`,
message: 'Enter new Section name:',
placeholder: 'e.g. Ideas, Tasks, Drafts',
confirmText: 'Create'
});
if (!name || !name.trim()) return;
const secName = name.trim();
if (!notedogState.sections.includes(secName)) {
notedogState.sections.push(secName);
}
notedogState.activeDbSection = secName;
renderDatabaseSidebar();
showToast(`Created Section "${secName}"`, 'success');
return;
}
if (!notedogState.activeNotebook) {
showToast('Please select or create a notebook first', 'info');
return;
}
const name = await showPromptDialog({
title: 'New Section',
subtitle: `Add section to Notebook "${notedogState.activeNotebook}"`,
message: 'Enter new Section name:',
placeholder: 'e.g. Ideas, Tasks, Drafts',
confirmText: 'Create'
});
if (!name || !name.trim()) return;
const secName = name.trim();
try {
const root = notedogState.rootFolder || '~/Notes';
const secPath = `${root}/${notedogState.activeNotebook}/${secName}`;
await fetch('/api/fs/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: secPath })
});
await loadNoteDogHierarchy();
selectNoteDogSection(secName);
showToast(`Created Section "${secName}"`, 'success');
} catch (err) {
showToast('Failed to create section', 'error');
}
}
async function toggleCurrentNoteEncryption() {
if (!notedogState.activeNote) {
showToast('No active note selected', 'warning');
return;
}
const isEnc = notedogState.activeNote.is_encrypted || notedogState.activeNote.filename?.endsWith('.md.enc');
if (isEnc) {
// Decrypt to plain Markdown (.md)
const confirmed = await showConfirmDialog({
title: 'Decrypt Note to Plain Markdown',
subtitle: `Convert "${notedogState.activeNote.filename}" to unencrypted .md`,
message: 'This will store the note in plaintext on disk. Are you sure?',
confirmText: 'Decrypt to Plain',
confirmIcon: 'unlock'
});
if (!confirmed) return;
let pass = notedogState.cachedPassphrases[notedogState.activeNote.path] || notedogState.cachedPassphrases[notedogState.activeSection] || notedogState.cachedPassphrases['__global'];
if (!pass) {
pass = await showPromptDialog({
title: 'Decrypt Note',
subtitle: notedogState.activeNote.filename || 'Encrypted Note',
message: 'Enter note passphrase to decrypt:',
placeholder: 'Passphrase',
inputType: 'password',
confirmText: 'Decrypt'
});
if (pass === null) return;
if (!pass) pass = 'notedog';
}
try {
const resp = await fetch('/api/tools/notedog/encrypt', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: notedogState.activeNote.path, content: notedogState.content, passphrase: pass, convert_to_plain: true })
});
if (resp.ok) {
const data = await resp.json();
showToast('Note converted to plain Markdown', 'success');
await loadNoteDogHierarchy(data.path);
} else {
showToast('Decryption failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Decryption failed: ' + err.message, 'error');
}
} else {
// Encrypt plain note (.md -> .md.enc)
const pass = await showPromptDialog({
title: 'Encrypt Note (ChaCha20-Poly1305 + Argon2id)',
subtitle: `Securing "${notedogState.activeNote.name}"`,
message: 'Enter encryption passphrase for this note:',
placeholder: 'Secret Passphrase',
inputType: 'password',
confirmText: 'Encrypt'
});
if (pass === null) return;
const passphrase = pass.trim() || 'notedog';
try {
const resp = await fetch('/api/tools/notedog/encrypt', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: notedogState.activeNote.path, content: notedogState.content, passphrase, convert_to_plain: false })
});
if (resp.ok) {
const data = await resp.json();
notedogState.cachedPassphrases[data.path] = passphrase;
if (notedogState.activeSection) notedogState.cachedPassphrases[notedogState.activeSection] = passphrase;
notedogState.cachedPassphrases['__global'] = passphrase;
notedogState.unlockedNotes[data.path] = true;
showToast('Note encrypted (.md.enc)', 'success');
await loadNoteDogHierarchy(data.path);
} else {
showToast('Encryption failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Encryption failed: ' + err.message, 'error');
}
}
}
function toggleNoteDogCreateEncryptedFields() {
const isEnc = document.getElementById('notedog-create-is-encrypted')?.checked;
const passGroup = document.getElementById('notedog-create-pass-group');
if (passGroup) passGroup.style.display = isEnc ? 'flex' : 'none';
}
function promptCreateNote(isEncrypted = false) {
const isDb = notedogState.storageMode === 'database';
if (!isDb && (!notedogState.activeNotebook || !notedogState.activeSection)) {
showToast('Please select a Notebook and Section first', 'info');
return;
}
const currentNb = !isDb ? notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook) : null;
const currentSec = currentNb?.sections.find(s => s.name === notedogState.activeSection);
const shouldEnc = !isDb && (isEncrypted || (currentSec && currentSec.is_encrypted) || (currentNb && currentNb.is_encrypted));
const titleInput = document.getElementById('notedog-create-title-input');
const encLabel = document.getElementById('notedog-create-is-encrypted-label') || document.getElementById('notedog-create-is-encrypted')?.closest('label');
const encCheck = document.getElementById('notedog-create-is-encrypted');
const passGroup = document.getElementById('notedog-create-pass-group');
const passInput = document.getElementById('notedog-create-pass');
const errEl = document.getElementById('notedog-create-error');
const modalTitle = document.getElementById('notedog-create-modal-title');
if (modalTitle) {
modalTitle.textContent = isDb ? 'Create New Note' : 'Create New Note';
}
if (titleInput) titleInput.value = '';
if (encLabel) encLabel.style.display = isDb ? 'none' : 'flex';
if (encCheck) encCheck.checked = !!shouldEnc;
if (passInput) passInput.value = !isDb ? (notedogState.cachedPassphrases[notedogState.activeSection] || notedogState.cachedPassphrases['__global'] || '') : '';
if (errEl) errEl.style.display = 'none';
if (isDb) {
if (passGroup) passGroup.style.display = 'none';
} else {
toggleNoteDogCreateEncryptedFields();
}
showModal('notedog-create-note-modal');
setTimeout(() => { if (titleInput) titleInput.focus(); }, 50);
const confirmBtn = document.getElementById('btn-notedog-create-confirm');
if (confirmBtn) {
confirmBtn.onclick = async () => {
const title = titleInput.value.trim();
if (!title) {
if (errEl) { errEl.textContent = 'Please enter a note title'; errEl.style.display = 'block'; }
return;
}
if (notedogState.storageMode === 'database') {
closeModal('notedog-create-note-modal');
await createDatabaseNote(title, `# ${title}\n\n`);
return;
}
const isEnc = encCheck ? encCheck.checked : false;
const pass = passInput ? passInput.value : '';
const root = notedogState.rootFolder || '~/Notes';
const secPath = `${root}/${notedogState.activeNotebook}/${notedogState.activeSection}`;
try {
const resp = await fetch('/api/tools/notedog/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
section_path: secPath,
title,
is_encrypted: isEnc,
passphrase: pass || null,
initial_content: `# ${title}\n\n`
})
});
if (resp.ok) {
const note = await resp.json();
if (isEnc && pass) {
notedogState.cachedPassphrases[note.path] = pass;
notedogState.cachedPassphrases[notedogState.activeSection] = pass;
notedogState.cachedPassphrases['__global'] = pass;
notedogState.unlockedNotes[note.path] = true;
}
closeModal('notedog-create-note-modal');
await loadNoteDogHierarchy(note.path);
showToast(`Created note "${title}"`, 'success');
} else {
const err = await resp.text();
if (errEl) { errEl.textContent = 'Failed: ' + sanitizeCredentials(err); errEl.style.display = 'block'; }
}
} catch (err) {
if (errEl) { errEl.textContent = 'Error: ' + err.message; errEl.style.display = 'block'; }
}
};
}
if (titleInput) {
titleInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
confirmBtn?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal('notedog-create-note-modal');
}
};
}
}
async function promptToggleEncryptSection() {
if (!notedogState.activeNotebook || !notedogState.activeSection) {
showToast('Please select a Section first', 'warning');
return;
}
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook);
const currentSec = currentNb?.sections.find(s => s.name === notedogState.activeSection);
if (!currentSec) return;
const isEnc = currentSec.is_encrypted;
const titleEl = document.getElementById('notedog-crypto-modal-title');
const descEl = document.getElementById('notedog-crypto-modal-desc');
const passInput = document.getElementById('notedog-crypto-pass');
const passConfirmInput = document.getElementById('notedog-crypto-pass-confirm');
const confirmGroup = document.getElementById('notedog-crypto-confirm-group');
const errEl = document.getElementById('notedog-crypto-error');
const confirmBtn = document.getElementById('btn-notedog-crypto-confirm');
if (titleEl) titleEl.textContent = isEnc ? `🔓 Decrypt Section "${currentSec.name}"` : `🔒 Encrypt Section "${currentSec.name}"`;
if (descEl) descEl.textContent = isEnc
? `Decrypt all .md.enc notes in section "${currentSec.name}" back to plain Markdown (.md).`
: `Encrypt all notes in section "${currentSec.name}" using ChaCha20-Poly1305 + Argon2id (.md.enc). NoteDog TUI compatible.`;
if (confirmGroup) confirmGroup.style.display = isEnc ? 'none' : 'flex';
if (passInput) passInput.value = notedogState.cachedPassphrases[currentSec.path] || notedogState.cachedPassphrases['__global'] || '';
if (passConfirmInput) passConfirmInput.value = '';
if (errEl) errEl.style.display = 'none';
showModal('notedog-crypto-modal');
setTimeout(() => { if (passInput) passInput.focus(); }, 50);
if (confirmBtn) {
confirmBtn.onclick = async () => {
const pass = passInput.value;
if (!pass) {
if (errEl) { errEl.textContent = 'Please enter a passphrase'; errEl.style.display = 'block'; }
return;
}
if (!isEnc && passConfirmInput.value !== pass) {
if (errEl) { errEl.textContent = 'Passphrases do not match'; errEl.style.display = 'block'; }
return;
}
const endpoint = isEnc ? '/api/tools/notedog/section/decrypt' : '/api/tools/notedog/section/encrypt';
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: currentSec.path, passphrase: pass })
});
if (resp.ok) {
const data = await resp.json();
if (!isEnc) {
notedogState.cachedPassphrases[currentSec.path] = pass;
notedogState.cachedPassphrases['__global'] = pass;
}
closeModal('notedog-crypto-modal');
await loadNoteDogHierarchy();
showToast(isEnc ? `Decrypted ${data.decrypted_count || 0} notes in section` : `Encrypted ${data.encrypted_count || 0} notes in section`, 'success');
} else {
const err = await resp.text();
if (errEl) { errEl.textContent = 'Action failed: ' + sanitizeCredentials(err); errEl.style.display = 'block'; }
}
} catch (err) {
if (errEl) { errEl.textContent = 'Error: ' + err.message; errEl.style.display = 'block'; }
}
};
}
}
async function promptToggleEncryptNotebook() {
if (!notedogState.activeNotebook) {
showToast('Please select a Notebook first', 'warning');
return;
}
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook);
if (!currentNb) return;
const isEnc = currentNb.is_encrypted;
const titleEl = document.getElementById('notedog-crypto-modal-title');
const descEl = document.getElementById('notedog-crypto-modal-desc');
const passInput = document.getElementById('notedog-crypto-pass');
const passConfirmInput = document.getElementById('notedog-crypto-pass-confirm');
const confirmGroup = document.getElementById('notedog-crypto-confirm-group');
const errEl = document.getElementById('notedog-crypto-error');
const confirmBtn = document.getElementById('btn-notedog-crypto-confirm');
if (titleEl) titleEl.textContent = isEnc ? `🔓 Decrypt Notebook "${currentNb.name}"` : `🔒 Encrypt Notebook "${currentNb.name}"`;
if (descEl) descEl.textContent = isEnc
? `Decrypt all sections and notes in notebook "${currentNb.name}" back to plain Markdown (.md).`
: `Encrypt all sections and notes in notebook "${currentNb.name}" using ChaCha20-Poly1305 + Argon2id (.md.enc). NoteDog TUI compatible.`;
if (confirmGroup) confirmGroup.style.display = isEnc ? 'none' : 'flex';
if (passInput) passInput.value = notedogState.cachedPassphrases['__global'] || '';
if (passConfirmInput) passConfirmInput.value = '';
if (errEl) errEl.style.display = 'none';
showModal('notedog-crypto-modal');
setTimeout(() => { if (passInput) passInput.focus(); }, 50);
if (confirmBtn) {
confirmBtn.onclick = async () => {
const pass = passInput.value;
if (!pass) {
if (errEl) { errEl.textContent = 'Please enter a passphrase'; errEl.style.display = 'block'; }
return;
}
if (!isEnc && passConfirmInput.value !== pass) {
if (errEl) { errEl.textContent = 'Passphrases do not match'; errEl.style.display = 'block'; }
return;
}
const endpoint = isEnc ? '/api/tools/notedog/notebook/decrypt' : '/api/tools/notedog/notebook/encrypt';
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: currentNb.path, passphrase: pass })
});
if (resp.ok) {
const data = await resp.json();
if (!isEnc) {
notedogState.cachedPassphrases['__global'] = pass;
}
closeModal('notedog-crypto-modal');
await loadNoteDogHierarchy();
showToast(isEnc ? `Decrypted ${data.decrypted_count || 0} notes in notebook` : `Encrypted ${data.encrypted_count || 0} notes in notebook`, 'success');
} else {
const err = await resp.text();
if (errEl) { errEl.textContent = 'Action failed: ' + sanitizeCredentials(err); errEl.style.display = 'block'; }
}
} catch (err) {
if (errEl) { errEl.textContent = 'Error: ' + err.message; errEl.style.display = 'block'; }
}
};
}
}
async function openNoteDogNewMenu(e) {
const choices = [
'1. 📄 New Note from Template',
'2. 📂 New Section',
'3. 📚 New Notebook'
];
const choice = await showPromptDialog({
title: 'Create Note Item',
subtitle: 'Notebooks, Sections & Templates',
message: 'Select creation type:\n1. 📄 New Note from Template\n2. 📂 New Section\n3. 📚 New Notebook',
defaultValue: '1',
placeholder: '1, 2, or 3',
confirmText: 'Select'
});
if (choice === '1') openNoteDogTemplatePicker();
else if (choice === '2') promptCreateSection();
else if (choice === '3') promptCreateNotebook();
}
async function openNoteDogTemplatePicker() {
if (!notedogState.activeNotebook || !notedogState.activeSection) {
showToast('Please select a Notebook and Section first', 'info');
return;
}
try {
const resp = await fetch('/api/tools/notedog/templates', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
const templates = resp.ok ? await resp.json() : [];
notedogState.templates = templates;
let templateMenu = 'Choose a Note Template:\n';
templates.forEach((t, i) => {
templateMenu += `${i + 1}. ${t.icon} ${t.name} - ${t.description}\n`;
});
const choice = await showPromptDialog({
title: 'Choose a Note Template',
subtitle: 'Markdown Templates & Layouts',
message: `${templateMenu}\nEnter template number (1-${templates.length}):`,
defaultValue: '1',
placeholder: `1-${templates.length}`,
confirmText: 'Next'
});
if (!choice) return;
const idx = parseInt(choice, 10) - 1;
const selectedTemplate = templates[idx] || templates[0];
const title = await showPromptDialog({
title: 'New Note Title',
subtitle: `Using "${selectedTemplate.name}" template`,
message: 'Enter note title:',
defaultValue: selectedTemplate.name,
placeholder: 'Note Title',
confirmText: 'Create Note'
});
if (!title || !title.trim()) return;
createNoteFromTemplate(selectedTemplate, title.trim());
} catch (err) {
console.error('NoteDog template picker error:', err);
}
}
async function createNoteFromTemplate(template, title) {
const now = new Date().toISOString().replace('T', ' ').substring(0, 16);
let content = template.content.replace(/\{\{title\}\}/g, title).replace(/\{\{date\}\}/g, now);
if (notedogState.storageMode === 'database') {
await createDatabaseNote(title, content);
return;
}
const root = notedogState.rootFolder || '~/Notes';
const cleanFilename = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
const notePath = `${root}/${notedogState.activeNotebook}/${notedogState.activeSection}/${cleanFilename}`;
try {
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: notePath, content })
});
if (resp.ok) {
await loadNoteDogHierarchy(notePath);
showToast(`Created note "${title}"`, 'success');
}
} catch (err) {
showToast('Failed to create note', 'error');
}
}
async function promptDeleteCurrentNote() {
if (notedogState.storageMode === 'database') {
if (!notedogState.activeDbNote) {
showToast('No active note selected', 'warning');
return;
}
await deleteActiveDatabaseNote();
return;
}
if (!notedogState.activeNote) return;
if (notedogState.activeNote.path?.startsWith('manual://') || notedogState.activeNote.is_manual) {
showToast('Built-in repository manuals cannot be deleted', 'warning');
return;
}
const noteName = notedogState.activeNote.name;
const confirmed = await showConfirmDialog({
title: 'Delete Note',
subtitle: 'Remove note file from disk',
message: `Are you sure you want to delete note "${noteName}"?`,
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete Note'
});
if (!confirmed) return;
try {
const resp = await fetch('/api/fs/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
paths: [notedogState.activeNote.path],
use_trash: false,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (resp.ok) {
notedogState.activeNote = null;
await loadNoteDogHierarchy();
refreshAllPanes();
showToast('Note deleted', 'info');
}
} catch (err) {
showToast('Failed to delete note', 'error');
}
}
async function openNoteDogVersionsModal() {
if (!notedogState.activeNote) {
showToast('Please open a note first to view revisions', 'info');
return;
}
showModal('notedog-versions-modal');
const noteNameBadge = document.getElementById('notedog-version-note-name');
if (noteNameBadge) noteNameBadge.textContent = notedogState.activeNote.filename;
const listEl = document.getElementById('notedog-versions-list');
const previewEl = document.getElementById('notedog-version-preview-content');
const restoreBtn = document.getElementById('btn-notedog-restore-version');
const metaLabel = document.getElementById('notedog-version-meta-label');
if (listEl) listEl.innerHTML = '<div style="padding: 10px; font-size: 11px; color: var(--text-dim);">Loading revisions...</div>';
if (previewEl) previewEl.textContent = '';
if (restoreBtn) restoreBtn.style.display = 'none';
try {
const resp = await fetch(`/api/tools/notedog/versions?path=${encodeURIComponent(notedogState.activeNote.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const versions = await resp.json();
if (versions.length === 0) {
if (listEl) listEl.innerHTML = '<div style="padding: 12px; font-size: 11px; color: var(--text-dim); text-align: center;">No revisions recorded yet. Revisions are created automatically when saving notes!</div>';
return;
}
if (listEl) {
listEl.innerHTML = versions.map((v, i) => `
<div class="notedog-item ${i === 0 ? 'active' : ''}" onclick="selectNoteDogVersion('${escapeHtml(v.path)}', '${escapeHtml(v.formatted_time)}', ${v.size_bytes})" style="padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04);">
<div style="flex:1;">
<div style="font-weight:600; font-size:11px;">${escapeHtml(v.formatted_time)}</div>
<div style="font-size:10px; color:var(--text-dim);">${formatFileSize(v.size_bytes)}</div>
</div>
</div>
`).join('');
}
// Auto-preview first version
selectNoteDogVersion(versions[0].path, versions[0].formatted_time, versions[0].size_bytes);
}
} catch (err) {
console.error('NoteDog load versions failed:', err);
}
}
async function selectNoteDogVersion(verPath, verTime, sizeBytes) {
notedogState.selectedVersion = { path: verPath, time: verTime };
const previewEl = document.getElementById('notedog-version-preview-content');
const metaLabel = document.getElementById('notedog-version-meta-label');
const restoreBtn = document.getElementById('btn-notedog-restore-version');
if (metaLabel) metaLabel.textContent = `Revision: ${verTime} (${formatFileSize(sizeBytes)})`;
if (restoreBtn) restoreBtn.style.display = 'inline-flex';
try {
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(verPath)}`);
if (resp.ok) {
const data = await resp.json();
if (previewEl) previewEl.textContent = data.content || '';
notedogState.selectedVersion.content = data.content || '';
}
} catch (err) {
if (previewEl) previewEl.textContent = 'Failed to load version content';
}
}
async function restoreSelectedNoteDogVersion() {
if (!notedogState.selectedVersion || !notedogState.activeNote) return;
const confirmed = await showConfirmDialog({
title: 'Restore Revision',
subtitle: 'Revert note content to selected snapshot',
message: `Restore revision from ${notedogState.selectedVersion.time}? Current unsaved edits will be replaced.`,
icon: 'history',
type: 'warning',
confirmText: 'Restore'
});
if (!confirmed) return;
const content = notedogState.selectedVersion.content || '';
const textarea = document.getElementById('notedog-editor-textarea');
if (textarea) textarea.value = content;
notedogState.content = content;
closeModal('notedog-versions-modal');
await saveActiveNoteDogNote();
renderNoteDogPreview(content);
syncNoteDogGutter();
showToast(`Restored revision from ${notedogState.selectedVersion.time}`, 'success');
}
function mountDockedNoteDog(paneIndex) {
const mountBody = document.getElementById(`docked-notedog-body-${paneIndex}`);
if (!mountBody) return;
const nbs = (notedogState.notebooks && notedogState.notebooks.length > 0) ? notedogState.notebooks : [DEFAULT_MANUALS_NOTEBOOK];
const currentNb = nbs.find(nb => nb.name === notedogState.activeNotebook || nb.name.replace(/^[^\w\s&]+/, '').trim() === (notedogState.activeNotebook || '').replace(/^[^\w\s&]+/, '').trim()) || nbs[0];
const currentSec = (currentNb?.sections || []).find(s => s.name === notedogState.activeSection || s.name.replace(/^[^\w\s&]+/, '').trim() === (notedogState.activeSection || '').replace(/^[^\w\s&]+/, '').trim()) || currentNb?.sections?.[0];
const currentNote = (currentSec?.notes || []).find(n => n.path === notedogState.activeNote?.path) || currentSec?.notes?.[0];
const mode = notedogState.viewMode || 'split';
mountBody.innerHTML = `
<div style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden;">
<div style="padding: 4px 8px; background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; gap: 4px; align-items: center; font-size: 11px;">
<select id="docked-notedog-nb-select-${paneIndex}" class="pane-quick-filter" style="font-size: 11px; padding: 2px 4px;" onchange="handleDockedNotebookChange(${paneIndex}, this.value)">
${nbs.map(nb => {
const clean = nb.name.replace(/^[^\w\s&]+/, '').trim();
const icon = (nb.path?.startsWith('manual://') || clean === 'Manuals') ? '📖' : '📚';
return `<option value="${escapeHtml(nb.name)}" ${nb.name === (currentNb?.name || '') ? 'selected' : ''}>${icon} ${escapeHtml(clean)}</option>`;
}).join('')}
</select>
<select id="docked-notedog-sec-select-${paneIndex}" class="pane-quick-filter" style="font-size: 11px; padding: 2px 4px;" onchange="handleDockedSectionChange(${paneIndex}, this.value)">
${((currentNb?.sections) || []).map(sec => {
const clean = sec.name.replace(/^[^\w\s&]+/, '').trim();
const isManualNb = currentNb?.path?.startsWith('manual://') || (currentNb?.name || '').replace(/^[^\w\s&]+/, '').trim() === 'Manuals';
const icon = isManualNb ? getManualSectionIcon(sec.name) : '📂';
return `<option value="${escapeHtml(sec.name)}" ${sec.name === (currentSec?.name || '') ? 'selected' : ''}>${icon} ${escapeHtml(clean)}</option>`;
}).join('')}
</select>
<select id="docked-notedog-note-select-${paneIndex}" class="pane-quick-filter" style="font-size: 11px; padding: 2px 4px; flex: 1;" onchange="handleDockedNoteSelect(${paneIndex}, this.value)">
${((currentSec?.notes) || []).map(n => `<option value="${escapeHtml(n.path)}" ${currentNote && n.path === currentNote.path ? 'selected' : ''}>📄 ${escapeHtml(n.name.replace(/^[^\w\s&]+/, '').trim())}</option>`).join('')}
</select>
</div>
<div class="notedog-panes-wrapper ${mode}-mode" style="flex: 1; height: 100%; display: flex; overflow: hidden;">
<div class="notedog-pane notedog-editor-pane" style="${mode === 'preview' ? 'display:none;' : (mode === 'edit' ? 'width:100%; display:flex;' : 'width:50%; display:flex;')} border-right: 1px solid var(--border); height: 100%;">
<textarea id="docked-notedog-textarea-${paneIndex}" class="notedog-textarea" style="width: 100%; height: 100%;" oninput="syncDockedNoteDogInput(${paneIndex}, this.value)">${escapeHtml(notedogState.content || '')}</textarea>
</div>
<div class="notedog-pane notedog-preview-pane" style="${mode === 'edit' ? 'display:none;' : (mode === 'preview' ? 'width:100%; display:flex;' : 'width:50%; display:flex;')} padding: 12px 16px; height: 100%; overflow-y: auto;">
<div id="docked-notedog-preview-${paneIndex}" class="notedog-preview-content"></div>
</div>
</div>
</div>
`;
const titleEl = document.getElementById(`docked-notedog-title-${paneIndex}`);
if (titleEl) {
const cleanTitle = (currentNote?.name || notedogState.activeNote?.name || 'Notes').replace(/^[^\w\s&]+/, '').trim();
titleEl.textContent = cleanTitle;
}
['edit', 'split', 'preview'].forEach(m => {
const dockedBtn = document.getElementById(`docked-notedog-mode-${m}-${paneIndex}`);
if (dockedBtn) dockedBtn.classList.toggle('active', m === mode);
});
renderDockedNoteDogPreview(paneIndex, notedogState.content || '');
if (window.lucide) lucide.createIcons();
}
async function handleDockedNotebookChange(paneIndex, nbName) {
await selectNoteDogNotebook(nbName);
mountDockedNoteDog(paneIndex);
}
async function handleDockedSectionChange(paneIndex, secName) {
await selectNoteDogSection(secName);
mountDockedNoteDog(paneIndex);
}
async function handleDockedNoteSelect(paneIndex, notePath) {
const currentNb = notedogState.notebooks.find(nb => nb.name === notedogState.activeNotebook);
const currentSec = currentNb?.sections.find(s => s.name === notedogState.activeSection);
const note = currentSec?.notes.find(n => n.path === notePath);
if (note) {
await selectNoteDogNote(note);
mountDockedNoteDog(paneIndex);
}
}
function syncDockedNoteDogInput(paneIndex, val) {
notedogState.content = val;
notedogState.isDirty = true;
const mainTextarea = document.getElementById('notedog-editor-textarea');
if (mainTextarea) mainTextarea.value = val;
renderNoteDogPreview(val);
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'notedog') {
const dTa = document.getElementById(`docked-notedog-textarea-${idx}`);
if (dTa && idx !== paneIndex) dTa.value = val;
renderDockedNoteDogPreview(idx, val);
}
});
}
function renderDockedNoteDogPreview(paneIndex, text) {
const preview = document.getElementById(`docked-notedog-preview-${paneIndex}`);
if (preview) {
preview.innerHTML = generateNoteDogMarkdownHtml(text);
if (typeof postProcessMarkdownContainer === 'function') {
postProcessMarkdownContainer(preview);
}
if (window.lucide) lucide.createIcons();
if (window.mermaid) {
try {
mermaid.run({ nodes: preview.querySelectorAll('.mermaid') });
} catch (e) {}
}
}
}
function renderEditorTabs() {
const container = document.getElementById('editor-tabs-container');
if (!container) return;
container.innerHTML = editorTabs.map(tab => {
const isActive = tab.id === activeTabIdLeft;
const isDirty = tab.isDirty;
const icon = getFileIconForExtension(tab.path || tab.filename);
return `
<div class="editor-tab ${isActive ? 'active' : ''}" onclick="switchActiveEditorTab('${tab.id}', 'left')" onauxclick="if (event.button === 1) closeEditorTab('${tab.id}')" title="${escapeHtml(tab.path || tab.filename)}">
<i data-lucide="${icon}" style="width: 13px; height: 13px;"></i>
<span>${escapeHtml(tab.filename)}</span>
${isDirty ? '<span class="editor-tab-dirty" title="Unsaved changes"></span>' : ''}
<span class="editor-tab-close" onclick="event.stopPropagation(); closeEditorTab('${tab.id}')" title="Close Tab (Ctrl+W)"><i data-lucide="x" style="width: 11px; height: 11px;"></i></span>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function getFileIconForExtension(path) {
const ext = (path || '').split('.').pop().toLowerCase();
if (['rs', 'js', 'ts', 'py', 'c', 'cpp', 'h', 'java', 'go', 'sh', 'sql', 'html', 'css'].includes(ext)) return 'file-code';
if (['json', 'yaml', 'yml', 'toml', 'ini', 'conf'].includes(ext)) return 'file-json';
if (['md', 'markdown', 'txt'].includes(ext)) return 'file-text';
return 'file';
}
// ---------------- SAVE OPERATIONS ----------------
async function saveActiveEditorTab() {
const tab = getActiveEditorTab('left');
if (!tab) return;
const textarea = document.getElementById('editor-text-left');
if (textarea) tab.content = textarea.value;
if (tab.isConfdAssembled && tab.confdFiles && tab.confdFiles.length > 0) {
await saveConfdAssembledTab(tab);
return;
}
if (!tab.path) {
const defaultName = tab.filename.startsWith('Untitled') ? 'newfile.txt' : tab.filename;
const defaultDir = (App.panes && App.panes[0] && App.panes[0].path && App.panes[0].path !== '/') ? App.panes[0].path : (App.username ? `/home/${App.username}` : '/');
const defaultFull = defaultDir.endsWith('/') ? `${defaultDir}${defaultName}` : `${defaultDir}/${defaultName}`;
const userPath = await showPromptDialog({
title: 'Save File As',
subtitle: 'Specify Destination Path',
message: 'Enter full file path to save:',
defaultValue: defaultFull,
placeholder: '/path/to/file.txt',
confirmText: 'Save File'
});
if (!userPath) return;
tab.path = sanitizeCredentials(userPath.trim());
tab.filename = getBasename(userPath.trim());
}
if (tab.path && tab.path.startsWith('client://')) {
try {
const fileHandle = await resolveClientFileHandle(tab.path, true);
const writable = await fileHandle.createWritable();
await writable.write(tab.content);
await writable.close();
tab.origContent = tab.content;
tab.isDirty = false;
flashSaveButton();
showToast(`Saved "${tab.filename}"!`, 'success');
renderEditorTabs();
refreshAllPanes();
return;
} catch (e) {
showToast('Save error: ' + sanitizeCredentials(String(e)), 'error');
return;
}
}
try {
const authPath = resolveAuthUri(tab.path);
const paneIdx = (tab.paneIndex !== undefined && tab.paneIndex !== null) ? tab.paneIndex : App.activePaneIndex;
const endpoint = getPaneEndpoint(paneIdx);
const authHeaders = getPaneAuthHeaders(paneIdx);
const resp = await fetch(`${endpoint}/api/fs/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ path: authPath, content: tab.content, atomic: true })
});
if (resp.ok) {
tab.origContent = tab.content;
tab.isDirty = false;
flashSaveButton();
showToast(`Saved "${tab.filename}"!`, 'success');
renderEditorTabs();
refreshAllPanes();
} else {
showToast('Save failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (e) {
showToast('Save error: ' + sanitizeCredentials(String(e)), 'error');
}
}
async function saveConfdAssembledTab(tab) {
const text = tab.content;
let savedCount = 0;
let errorCount = 0;
for (const part of tab.confdFiles) {
const startTag = `[FILE START: ${part.filename}]`;
const endTag = `[FILE END: ${part.filename}]`;
const startIdx = text.indexOf(startTag);
const endIdx = text.indexOf(endTag);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
const afterHeader = text.indexOf('\n', startIdx);
const beforeFooter = text.lastIndexOf('\n', endIdx);
const segment = text.substring(afterHeader + 1, beforeFooter);
const cleanContent = segment.replace(/^# =+\n?/, '').replace(/# =+\n?$/, '');
try {
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: part.path, content: cleanContent, atomic: true })
});
if (resp.ok) {
savedCount++;
part.content = cleanContent;
} else {
errorCount++;
}
} catch (err) {
errorCount++;
}
}
}
tab.origContent = tab.content;
tab.isDirty = false;
flashSaveButton();
renderEditorTabs();
refreshAllPanes();
if (errorCount === 0) {
showToast(`Saved all ${savedCount} modular config files!`, 'success');
} else {
showToast(`Saved ${savedCount} files (${errorCount} failed)`, 'error');
}
}
async function saveAllEditorTabs() {
let count = 0;
for (const tab of editorTabs) {
if (tab.isDirty) {
if (tab.isConfdAssembled) {
await saveConfdAssembledTab(tab);
} else if (tab.path) {
try {
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: tab.path, content: tab.content, atomic: true })
});
if (resp.ok) {
tab.origContent = tab.content;
tab.isDirty = false;
count++;
}
} catch (e) {
console.warn('Save all error:', e);
}
}
}
}
renderEditorTabs();
refreshAllPanes();
showToast(`Saved ${count} open file(s)!`, 'success');
}
function flashSaveButton() {
const btn = document.getElementById('btn-save-editor');
if (btn) {
const origHtml = btn.innerHTML;
btn.innerHTML = '<i data-lucide="check"></i>';
btn.classList.add('btn-saved');
if (window.lucide) lucide.createIcons();
setTimeout(() => {
btn.innerHTML = origHtml;
btn.classList.remove('btn-saved');
if (window.lucide) lucide.createIcons();
}, 1400);
}
}
// ---------------- VIEW MODES & LANGUAGE ----------------
function handleEditorViewModeChange(mode) {
editorViewMode = mode;
const grid = document.getElementById('editor-grid-container');
const rightPane = document.getElementById('editor-right-pane');
const preview = document.getElementById('editor-preview-container');
const bodyWrapperRight = document.getElementById('editor-body-wrapper-right');
const rightTitle = document.getElementById('editor-file-title-right');
const rightTag = document.getElementById('editor-file-tag-right');
// Update active state on layout buttons
const modeBtnMap = {
'single-editor': 'btn-view-single',
'dual-vertical': 'btn-view-dual-v',
'dual-horizontal': 'btn-view-dual-h',
'split-markdown': 'btn-view-preview'
};
document.querySelectorAll('.editor-view-mode-btn').forEach(b => b.classList.remove('active'));
const activeBtnId = modeBtnMap[mode];
if (activeBtnId) {
const activeBtn = document.getElementById(activeBtnId);
if (activeBtn) activeBtn.classList.add('active');
}
if (!grid || !rightPane) return;
grid.className = `editor-grid ${mode}`;
if (mode === 'single-editor') {
rightPane.style.display = 'none';
} else if (mode === 'split-markdown') {
rightPane.style.display = 'flex';
if (preview) preview.style.display = 'block';
if (bodyWrapperRight) bodyWrapperRight.style.display = 'none';
if (rightTitle) rightTitle.textContent = 'Live Markdown & Mermaid Preview';
if (rightTag) rightTag.textContent = 'PREVIEW';
updateMarkdownPreview();
} else if (mode === 'dual-vertical' || mode === 'dual-horizontal') {
rightPane.style.display = 'flex';
if (preview) preview.style.display = 'none';
if (bodyWrapperRight) bodyWrapperRight.style.display = 'flex';
if (!activeTabIdRight || activeTabIdRight === activeTabIdLeft) {
const otherTab = editorTabs.find(t => t.id !== activeTabIdLeft) || editorTabs[0];
if (otherTab) activeTabIdRight = otherTab.id;
}
const rightTab = getActiveEditorTab('right');
if (rightTab) {
if (rightTitle) rightTitle.textContent = rightTab.path ? sanitizeCredentials(rightTab.path) : rightTab.filename;
if (rightTag) rightTag.textContent = rightTab.lang.toUpperCase();
const textareaRight = document.getElementById('editor-text-right');
if (textareaRight) textareaRight.value = rightTab.content;
updateEditorGutter('right');
}
}
}
function formatEditorLangBadge(lang, isConfd) {
if (isConfd) return 'CONFD ▾';
if (!lang || lang === 'auto') return 'AUTO ▾';
const nameMap = {
'rust': 'RUST',
'python': 'PYTHON',
'javascript': 'JS / TS',
'html': 'HTML',
'css': 'CSS',
'json': 'JSON',
'yaml': 'YAML',
'toml': 'TOML',
'bash': 'BASH',
'sql': 'SQL',
'markdown': 'MARKDOWN',
'clike': 'C / C++',
'docker': 'DOCKER',
'ini': 'INI'
};
return (nameMap[lang.toLowerCase()] || lang.toUpperCase()) + ' ▾';
}
function handleEditorLanguageChange(lang) {
const tab = getActiveEditorTab('left');
if (tab) {
tab.lang = lang === 'auto' ? (tab.path ? detectLanguageFromPath(tab.path) : 'markup') : lang;
const tagEl = document.getElementById('editor-file-tag-left');
if (tagEl) tagEl.textContent = tab.lang.toUpperCase();
const langBadge = document.getElementById('editor-language-badge');
if (langBadge) {
langBadge.textContent = formatEditorLangBadge(tab.lang, tab.isConfdAssembled);
}
const mobileSelect = document.getElementById('editor-language-select-mobile');
if (mobileSelect) mobileSelect.value = lang;
const desktopSelect = document.getElementById('editor-language-select');
if (desktopSelect) desktopSelect.value = lang;
if (editorViewMode === 'split-markdown') updateMarkdownPreview();
}
}
// ---------------- GUTTER & INPUT HANDLERS ----------------
function handleEditorInput(pane = 'left') {
const textarea = document.getElementById(`editor-text-${pane}`);
if (!textarea) return;
const content = textarea.value;
const tab = getActiveEditorTab(pane);
if (tab) {
tab.content = content;
tab.isDirty = (tab.content !== tab.origContent);
}
updateEditorGutter(pane);
updateEditorStatusBar(pane);
renderEditorTabs();
if (editorViewMode === 'split-markdown' && pane === 'left') {
updateMarkdownPreview();
}
}
function handleEditorClick(pane = 'left') {
updateEditorStatusBar(pane);
}
function updateEditorGutter(pane = 'left') {
const textarea = document.getElementById(`editor-text-${pane}`);
const gutter = document.getElementById(`editor-gutter-${pane}`);
if (!textarea || !gutter) return;
const lineCount = textarea.value.split('\n').length;
let gutterText = '';
for (let i = 1; i <= lineCount; i++) {
gutterText += i + '\n';
}
gutter.textContent = gutterText;
}
function syncGutterScroll(pane = 'left') {
const textarea = document.getElementById(`editor-text-${pane}`);
const gutter = document.getElementById(`editor-gutter-${pane}`);
if (textarea && gutter) {
gutter.scrollTop = textarea.scrollTop;
}
if (pane === 'left' && editorViewMode === 'split-markdown') {
const preview = document.getElementById('editor-preview-container');
if (preview && textarea) {
const scrollRatio = textarea.scrollTop / Math.max(1, (textarea.scrollHeight - textarea.clientHeight));
preview.scrollTop = scrollRatio * (preview.scrollHeight - preview.clientHeight);
}
}
}
function updateEditorStatusBar(pane = 'left') {
const textarea = document.getElementById(`editor-text-${pane}`);
if (!textarea) return;
const content = textarea.value;
const pos = textarea.selectionStart || 0;
const selEnd = textarea.selectionEnd || 0;
const selLen = Math.abs(selEnd - pos);
const lines = content.substring(0, pos).split('\n');
const lineNum = lines.length;
const colNum = lines[lines.length - 1].length + 1;
const posEl = document.getElementById('editor-cursor-pos');
if (posEl) {
posEl.textContent = `Ln ${lineNum}, Col ${colNum}${selLen > 0 ? ` (${selLen} selected)` : ''}`;
}
const totalLines = content.split('\n').length;
const totalWords = content.split(/\s+/).filter(Boolean).length;
const statsEl = document.getElementById('editor-doc-stats');
if (statsEl) {
statsEl.textContent = `${totalLines} lines | ${content.length} chars | ${totalWords} words`;
}
const tab = getActiveEditorTab(pane);
const langBadge = document.getElementById('editor-language-badge');
if (langBadge && tab) {
langBadge.textContent = formatEditorLangBadge(tab.lang, tab.isConfdAssembled);
}
}
function cycleEditorTabSize() {
editorTabSize = editorTabSize === 2 ? 4 : 2;
const info = document.getElementById('editor-indent-info');
if (info) info.textContent = `Spaces: ${editorTabSize}`;
['left', 'right'].forEach(pane => {
const ta = document.getElementById(`editor-text-${pane}`);
if (ta) ta.style.tabSize = editorTabSize;
});
}
function handleEditorKeyDown(e, pane = 'left') {
const textarea = e.target;
const tabSpaces = ' '.repeat(editorTabSize);
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (e.shiftKey) {
const lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
const lineText = textarea.value.substring(lineStart);
if (lineText.startsWith(' ')) {
textarea.value = textarea.value.substring(0, lineStart) + textarea.value.substring(lineStart + 2);
textarea.selectionStart = Math.max(0, start - 2);
textarea.selectionEnd = Math.max(0, end - 2);
}
} else {
textarea.value = textarea.value.substring(0, start) + tabSpaces + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + tabSpaces.length;
}
handleEditorInput(pane);
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (e.shiftKey) saveAllEditorTabs();
else saveActiveEditorTab();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
const tab = getActiveEditorTab(pane);
if (tab) closeEditorTab(tab.id);
} else if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault();
createNewEditorTab();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
toggleFindBar(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
toggleFindBar(true);
document.getElementById('editor-replace-input')?.focus();
} else if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
toggleLineComment(textarea, pane);
} else if (e.altKey && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
textarea.style.whiteSpace = textarea.style.whiteSpace === 'pre-wrap' ? 'pre' : 'pre-wrap';
showToast(`Word wrap: ${textarea.style.whiteSpace === 'pre-wrap' ? 'ON' : 'OFF'}`, 'info');
}
}
function toggleLineComment(textarea, pane) {
const tab = getActiveEditorTab(pane);
const lang = tab?.lang || 'markup';
let commentPrefix = '# ';
if (['javascript', 'rust', 'c', 'cpp', 'clike', 'java', 'go'].includes(lang)) commentPrefix = '// ';
else if (['sql'].includes(lang)) commentPrefix = '-- ';
else if (['html', 'xml'].includes(lang)) commentPrefix = '<!-- ';
const start = textarea.selectionStart;
const lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = textarea.value.indexOf('\n', start);
const currentLine = textarea.value.substring(lineStart, lineEnd === -1 ? textarea.value.length : lineEnd);
if (currentLine.startsWith(commentPrefix)) {
textarea.value = textarea.value.substring(0, lineStart) + currentLine.substring(commentPrefix.length) + textarea.value.substring(lineEnd === -1 ? textarea.value.length : lineEnd);
} else {
textarea.value = textarea.value.substring(0, lineStart) + commentPrefix + currentLine + textarea.value.substring(lineEnd === -1 ? textarea.value.length : lineEnd);
}
handleEditorInput(pane);
}
function updateMarkdownPreview() {
const content = document.getElementById('editor-text-left')?.value || '';
const preview = document.getElementById('editor-preview-container');
if (!preview) return;
preview.innerHTML = renderMarkdownToHtml(content);
postProcessMarkdownContainer(preview);
}
function renderMarkdownToHtml(content) {
if (!content) return '<p style="color: var(--text-muted); font-style: italic;">(Empty markdown document)</p>';
// Transform GitHub alert callouts: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
let transformed = content.replace(/^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/gim, (match, type, text) => {
const alertType = type.toUpperCase();
const alertIcons = {
NOTE: 'info',
TIP: 'lightbulb',
IMPORTANT: 'alert-circle',
WARNING: 'alert-triangle',
CAUTION: 'alert-octagon'
};
const alertColors = {
NOTE: '#3b82f6',
TIP: '#10b981',
IMPORTANT: '#8b5cf6',
WARNING: '#f59e0b',
CAUTION: '#ef4444'
};
return `<div class="markdown-alert markdown-alert-${alertType.toLowerCase()}" style="border-left: 4px solid ${alertColors[alertType]}; padding: 8px 14px; margin: 12px 0; background: rgba(255,255,255,0.03); border-radius: 0 6px 6px 0;"><strong style="color: ${alertColors[alertType]}; display: flex; align-items: center; gap: 6px;"><i data-lucide="${alertIcons[alertType] || 'info'}" style="width: 14px; height: 14px;"></i> ${alertType}</strong> ${text}</div>`;
});
let html = '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
marked.setOptions({
gfm: true,
breaks: true,
headerIds: true,
mangle: false
});
html = marked.parse(transformed);
} catch (e) {
console.warn('marked.parse error, using fallback markdown renderer:', e);
html = fallbackRenderMarkdown(transformed);
}
} else {
html = fallbackRenderMarkdown(transformed);
}
return html;
}
function postProcessMarkdownContainer(container) {
if (!container) return;
// Convert Mermaid code blocks into rendered diagrams
if (window.mermaid) {
container.querySelectorAll('pre code.language-mermaid').forEach(codeEl => {
const pre = codeEl.closest('pre');
const mermaidDiv = document.createElement('div');
mermaidDiv.className = 'mermaid';
mermaidDiv.textContent = codeEl.textContent;
if (pre && pre.parentNode) {
pre.parentNode.replaceChild(mermaidDiv, pre);
}
});
const mermaidDivs = container.querySelectorAll('.mermaid');
if (mermaidDivs.length > 0) {
try {
mermaid.init(undefined, mermaidDivs);
} catch (err) {
console.warn('Mermaid render error:', err);
}
}
}
// Syntax highlighting with Prism
if (window.Prism) {
container.querySelectorAll('pre code').forEach(codeBlock => {
if (!codeBlock.closest('.mermaid')) {
Prism.highlightElement(codeBlock);
}
});
}
// Attach 1-click Copy button to all code blocks
container.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.code-copy-btn')) return;
pre.style.position = 'relative';
const copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.style.cssText = 'position: absolute; top: 6px; right: 6px; padding: 2px 6px; font-size: 10px; opacity: 0.7; z-index: 10; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-header); color: var(--text-main); cursor: pointer; display: flex; align-items: center; gap: 4px;';
copyBtn.innerHTML = '<i data-lucide="copy" style="width: 11px; height: 11px;"></i> Copy';
copyBtn.onclick = (e) => {
e.stopPropagation();
const codeText = pre.querySelector('code')?.innerText || pre.innerText;
navigator.clipboard.writeText(codeText);
copyBtn.innerHTML = '<i data-lucide="check" style="width: 11px; height: 11px; color: var(--success);"></i> Copied';
setTimeout(() => {
copyBtn.innerHTML = '<i data-lucide="copy" style="width: 11px; height: 11px;"></i> Copy';
if (window.lucide) lucide.createIcons({ root: copyBtn });
}, 1500);
};
pre.appendChild(copyBtn);
});
// Render Lucide icons
if (window.lucide) {
lucide.createIcons({ root: container });
}
}
function fallbackRenderMarkdown(content) {
if (!content) return '<p style="color: var(--text-muted); font-style: italic;">(Empty markdown document)</p>';
const lines = content.split('\n');
let inCodeBlock = false;
let codeBlockLang = '';
let codeBlockContent = [];
let inTable = false;
let tableRows = [];
let outHtml = [];
const inlineFormat = (txt) => {
return txt
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
};
const flushTable = () => {
if (!inTable || tableRows.length === 0) return;
let tbl = '<table>';
if (tableRows.length > 0) {
tbl += '<thead><tr>';
tableRows[0].forEach(cell => {
tbl += `<th>${inlineFormat(cell.trim())}</th>`;
});
tbl += '</tr></thead>';
}
if (tableRows.length > 1) {
tbl += '<tbody>';
for (let r = 1; r < tableRows.length; r++) {
tbl += '<tr>';
tableRows[r].forEach(cell => {
tbl += `<td>${inlineFormat(cell.trim())}</td>`;
});
tbl += '</tr>';
}
tbl += '</tbody>';
}
tbl += '</table>';
outHtml.push(tbl);
inTable = false;
tableRows = [];
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
const code = codeBlockContent.join('\n');
if (codeBlockLang === 'mermaid') {
outHtml.push(`<div class="mermaid">${escapeHtml(code)}</div>`);
} else {
outHtml.push(`<pre class="language-${codeBlockLang}"><code class="language-${codeBlockLang}">${escapeHtml(code)}</code></pre>`);
}
inCodeBlock = false;
codeBlockLang = '';
codeBlockContent = [];
} else {
flushTable();
inCodeBlock = true;
codeBlockLang = line.trim().replace(/^```/, '').trim();
codeBlockContent = [];
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
const cells = line.trim().slice(1, -1).split('|');
const isSep = cells.every(c => /^[\s-:]+$/.test(c));
if (!isSep) {
inTable = true;
tableRows.push(cells);
}
continue;
} else {
flushTable();
}
// Pass through direct HTML tags
if (/^\s*<(\/?[a-zA-Z][a-zA-Z0-9]*(\s+[^>]*)?\/?>)\s*$/.test(line) || /^\s*<(details|summary|div|p|span|img|kbd|table|tr|td|th|thead|tbody|tfoot|h[1-6]|ul|ol|li|blockquote|pre|code|hr|br|b|strong|i|em|u|del|s|center|figure|figcaption|video|audio|source|svg|path)[>\s]/i.test(line)) {
outHtml.push(line);
continue;
}
if (line.startsWith('# ')) {
outHtml.push(`<h1>${inlineFormat(line.slice(2))}</h1>`);
} else if (line.startsWith('## ')) {
outHtml.push(`<h2>${inlineFormat(line.slice(3))}</h2>`);
} else if (line.startsWith('### ')) {
outHtml.push(`<h3>${inlineFormat(line.slice(4))}</h3>`);
} else if (line.startsWith('#### ')) {
outHtml.push(`<h4>${inlineFormat(line.slice(5))}</h4>`);
} else if (line.startsWith('##### ')) {
outHtml.push(`<h5>${inlineFormat(line.slice(6))}</h5>`);
} else if (line.startsWith('###### ')) {
outHtml.push(`<h6>${inlineFormat(line.slice(7))}</h6>`);
} else if (line.startsWith('> ')) {
outHtml.push(`<blockquote><p>${inlineFormat(line.slice(2))}</p></blockquote>`);
} else if (line.startsWith('- [x] ') || line.startsWith('* [x] ')) {
outHtml.push(`<li class="task-list-item"><input type="checkbox" checked disabled><span style="text-decoration: line-through; opacity: 0.7;">${inlineFormat(line.slice(6))}</span></li>`);
} else if (line.startsWith('- [ ] ') || line.startsWith('* [ ] ')) {
outHtml.push(`<li class="task-list-item"><input type="checkbox" disabled><span>${inlineFormat(line.slice(6))}</span></li>`);
} else if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('+ ')) {
outHtml.push(`<li>${inlineFormat(line.slice(2))}</li>`);
} else if (/^\d+\.\s/.test(line)) {
const match = line.match(/^(\d+\.)\s(.*)/);
outHtml.push(`<li><b>${match[1]}</b> ${inlineFormat(match[2])}</li>`);
} else if (line.trim() === '---' || line.trim() === '***' || line.trim() === '___') {
outHtml.push('<hr>');
} else if (line.trim() === '') {
outHtml.push('<br>');
} else {
outHtml.push(`<p>${inlineFormat(line)}</p>`);
}
}
flushTable();
return outHtml.join('\n');
}
// ---------------- ADVANCED FIND & REPLACE TOOLBAR ----------------
function toggleFindBar(forceOpen) {
const bar = document.getElementById('editor-find-bar');
if (!bar) return;
const isOpening = forceOpen !== undefined ? forceOpen : bar.style.display === 'none';
bar.style.display = isOpening ? 'block' : 'none';
if (isOpening) {
const findInput = document.getElementById('editor-find-input');
findInput?.focus();
findInput?.select();
executeFind();
}
}
function toggleFindOption(opt) {
findOptions[opt] = !findOptions[opt];
const btnMap = {
matchCase: 'find-opt-case',
matchWord: 'find-opt-word',
useRegex: 'find-opt-regex'
};
const btn = document.getElementById(btnMap[opt]);
if (btn) btn.classList.toggle('active', findOptions[opt]);
executeFind();
}
function handleFindKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) findPrevMatch();
else findNextMatch();
} else if (e.key === 'Escape') {
toggleFindBar(false);
}
}
function executeFind() {
const query = document.getElementById('editor-find-input')?.value;
const textarea = document.getElementById('editor-text-left');
const countEl = document.getElementById('editor-match-count');
editorFindMatches = [];
if (!query || !textarea) {
if (countEl) countEl.textContent = '0 of 0';
return;
}
const text = textarea.value;
try {
let pattern = query;
if (!findOptions.useRegex) {
pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
if (findOptions.matchWord) {
pattern = `\\b${pattern}\\b`;
}
const flags = findOptions.matchCase ? 'g' : 'gi';
const regex = new RegExp(pattern, flags);
let match;
while ((match = regex.exec(text)) !== null) {
editorFindMatches.push({ start: match.index, end: match.index + match[0].length });
if (match.index === regex.lastIndex) regex.lastIndex++;
}
if (editorFindMatches.length > 0) {
if (currentMatchIndex < 0 || currentMatchIndex >= editorFindMatches.length) {
currentMatchIndex = 0;
}
highlightCurrentMatch();
} else {
currentMatchIndex = -1;
}
if (countEl) {
countEl.textContent = editorFindMatches.length > 0 ? `${currentMatchIndex + 1} of ${editorFindMatches.length}` : '0 of 0';
}
} catch (err) {
if (countEl) countEl.textContent = 'Regex error';
}
}
function highlightCurrentMatch() {
if (currentMatchIndex < 0 || currentMatchIndex >= editorFindMatches.length) return;
const match = editorFindMatches[currentMatchIndex];
const textarea = document.getElementById('editor-text-left');
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(match.start, match.end);
const countEl = document.getElementById('editor-match-count');
if (countEl) countEl.textContent = `${currentMatchIndex + 1} of ${editorFindMatches.length}`;
}
function findNextMatch() {
if (editorFindMatches.length === 0) return;
currentMatchIndex = (currentMatchIndex + 1) % editorFindMatches.length;
highlightCurrentMatch();
}
function findPrevMatch() {
if (editorFindMatches.length === 0) return;
currentMatchIndex = (currentMatchIndex - 1 + editorFindMatches.length) % editorFindMatches.length;
highlightCurrentMatch();
}
function replaceCurrentMatch() {
if (currentMatchIndex < 0 || currentMatchIndex >= editorFindMatches.length) return;
const match = editorFindMatches[currentMatchIndex];
const replaceWith = document.getElementById('editor-replace-input')?.value || '';
const textarea = document.getElementById('editor-text-left');
if (!textarea) return;
const val = textarea.value;
textarea.value = val.substring(0, match.start) + replaceWith + val.substring(match.end);
handleEditorInput('left');
executeFind();
}
function replaceAllMatches() {
const query = document.getElementById('editor-find-input')?.value;
const replaceWith = document.getElementById('editor-replace-input')?.value || '';
const textarea = document.getElementById('editor-text-left');
if (!query || !textarea) return;
try {
let pattern = query;
if (!findOptions.useRegex) {
pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
if (findOptions.matchWord) {
pattern = `\\b${pattern}\\b`;
}
const flags = findOptions.matchCase ? 'g' : 'gi';
const regex = new RegExp(pattern, flags);
textarea.value = textarea.value.replace(regex, replaceWith);
handleEditorInput('left');
executeFind();
showToast('Replaced all matches!', 'success');
} catch (err) {
showToast('Replace error: ' + err, 'error');
}
}
// ---------------- REVISED COMPARISON & DIFF ENGINE ----------------
let currentFolderDiffData = null;
let currentDiffFilter = 'all';
async function triggerDiff(deepHash = false) {
const paneA = App.panes[0];
const paneB = App.panes[1] || App.panes[0];
// Scenario 1: User selected 1 file in Pane 1 and 1 file in Pane 2
const selA = Array.from(paneA.selected);
const selB = Array.from(paneB.selected);
if (selA.length === 1 && selB.length === 1 && selA[0] !== selB[0]) {
return openFileDiffView(selA[0], selB[0]);
}
// Scenario 2: User selected 2 files in the SAME active pane
const activePane = App.panes[App.activePaneIndex];
const activeSel = Array.from(activePane.selected);
if (activeSel.length === 2) {
return openFileDiffView(activeSel[0], activeSel[1]);
}
// Scenario 3: Folder Comparison (Fast Immediate Level by default, Deep SHA-256 on demand)
const modalBody = document.getElementById('diff-modal-body');
modalBody.innerHTML = `
<div style="padding: 24px; text-align: center; color: var(--accent);">
<div style="font-size: 20px; font-weight: 700; margin-bottom: 8px;">⚖️ Calculating Folder Comparison...</div>
<div style="font-size: 12px; color: var(--text-muted);">${escapeHtml(sanitizeCredentials(paneA.path))} ⟷ ${escapeHtml(sanitizeCredentials(paneB.path))}</div>
<div style="margin-top: 12px; font-size: 11px; color: var(--text-dim);">${deepHash ? 'Performing deep recursive SHA-256 integrity analysis...' : 'Comparing immediate directory entries (fast mode)...'}</div>
</div>
`;
showModal('diff-modal');
try {
const selectedItems = activeSel.length > 0 ? activeSel.map(p => p.split('/').pop()) : null;
const resp = await fetch('/api/tools/diff/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
dir_left: paneA.path,
dir_right: paneB.path,
recursive: deepHash,
deep_hash: deepHash,
selected_items: selectedItems
})
});
if (resp.ok) {
currentFolderDiffData = await resp.json();
currentDiffFilter = 'all';
renderFolderDiff(currentFolderDiffData, currentDiffFilter);
} else {
modalBody.innerHTML = `<div style="color: var(--danger); padding: 20px;">Diff calculation failed: ${await resp.text()}</div>`;
}
} catch (e) {
modalBody.innerHTML = `<div style="color: var(--danger); padding: 20px;">Error: ${e}</div>`;
}
}
function setDiffFilter(filter) {
currentDiffFilter = filter;
if (currentFolderDiffData) renderFolderDiff(currentFolderDiffData, currentDiffFilter);
}
function renderFolderDiff(diff, filter = 'all') {
const body = document.getElementById('diff-modal-body');
if (!body) return;
let filteredEntries = diff.entries || [];
if (filter === 'modified') filteredEntries = filteredEntries.filter(e => e.status === 'modified');
else if (filter === 'left_only') filteredEntries = filteredEntries.filter(e => e.status === 'left_only');
else if (filter === 'right_only') filteredEntries = filteredEntries.filter(e => e.status === 'right_only');
else if (filter === 'identical') filteredEntries = filteredEntries.filter(e => e.status === 'identical');
body.innerHTML = `
<div class="diff-paths-card">
<div class="diff-path-chips">
<div class="diff-path-chip" title="${escapeHtml(diff.dir_left)}">
<span style="font-weight: 700; color: var(--info);">L:</span>
<code>${escapeHtml(diff.dir_left)}</code>
</div>
<span style="color: var(--text-dim); font-size: 11px;">⟷</span>
<div class="diff-path-chip" title="${escapeHtml(diff.dir_right)}">
<span style="font-weight: 700; color: #c084fc;">R:</span>
<code>${escapeHtml(diff.dir_right)}</code>
</div>
</div>
<div class="diff-action-buttons">
<button class="btn btn-sm" onclick="triggerDiff(false)" title="Fast Immediate Compare"><i data-lucide="zap"></i> Fast</button>
<button class="btn btn-sm btn-accent" onclick="triggerDiff(true)" title="Deep Recursive SHA-256 Compare"><i data-lucide="shield-check"></i> Deep Hash</button>
</div>
</div>
<!-- Filter Buttons Bar -->
<div class="diff-filter-bar">
<button class="diff-filter-btn ${filter === 'all' ? 'active' : ''}" onclick="setDiffFilter('all')">
All (${diff.entries.length})
</button>
<button class="diff-filter-btn ${filter === 'modified' ? 'active' : ''}" onclick="setDiffFilter('modified')">
<span style="color: var(--accent);">⚠️ Modified (${diff.modified_count})</span>
</button>
<button class="diff-filter-btn ${filter === 'left_only' ? 'active' : ''}" onclick="setDiffFilter('left_only')">
<span style="color: var(--info);">⬅ Left Only (${diff.left_only_count})</span>
</button>
<button class="diff-filter-btn ${filter === 'right_only' ? 'active' : ''}" onclick="setDiffFilter('right_only')">
<span style="color: var(--danger);">➡ Right Only (${diff.right_only_count})</span>
</button>
<button class="diff-filter-btn ${filter === 'identical' ? 'active' : ''}" onclick="setDiffFilter('identical')">
<span style="color: var(--success);">✔ Identical (${diff.identical_count})</span>
</button>
</div>
<div class="diff-table-wrapper">
<table class="diff-table">
<thead>
<tr>
<th style="text-align: left;">Relative Path</th>
<th style="width: 85px; text-align: center;">Status</th>
<th style="width: 80px; text-align: right;">Left Size</th>
<th style="width: 80px; text-align: right;">Right Size</th>
<th style="width: 60px; text-align: center;">Diff</th>
</tr>
</thead>
<tbody>
${filteredEntries.length === 0 ? '<tr><td colspan="5" style="text-align:center; padding: 28px; color: var(--text-dim);">No files match this filter.</td></tr>' : ''}
${filteredEntries.map(e => `
<tr>
<td style="font-family: var(--font-mono); max-width: 320px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(e.relative_path)}">
${escapeHtml(e.relative_path)}
</td>
<td style="text-align: center; color: ${e.status === 'identical' ? 'var(--success)' : (e.status === 'modified' ? 'var(--accent)' : (e.status === 'left_only' ? 'var(--info)' : 'var(--danger)'))}; font-weight: 700; font-size: 10px;">
${e.status.replace('_', ' ').toUpperCase()}
</td>
<td style="text-align: right; font-family: var(--font-mono); color: var(--text-muted);">${e.size_left !== null ? formatBytes(e.size_left) : '-'}</td>
<td style="text-align: right; font-family: var(--font-mono); color: var(--text-muted);">${e.size_right !== null ? formatBytes(e.size_right) : '-'}</td>
<td style="text-align: center;">
${!e.is_dir && (e.status === 'modified' || e.status === 'identical') ? `
<button class="btn btn-icon btn-sm" onclick="openFileDiffView('${escapeHtml(diff.dir_left)}/${escapeHtml(e.relative_path)}', '${escapeHtml(diff.dir_right)}/${escapeHtml(e.relative_path)}')" title="Inspect Side-by-Side Diff">
<i data-lucide="eye" style="width:12px;"></i>
</button>
` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
if (window.lucide) lucide.createIcons();
}
async function openFileDiffView(fileL, fileR) {
showModal('diff-modal');
const body = document.getElementById('diff-modal-body');
if (!body) return;
body.innerHTML = '<div style="padding: 30px; text-align: center; color: var(--accent);"><i data-lucide="loader"></i> Loading side-by-side file comparison...</div>';
if (window.lucide) lucide.createIcons();
try {
let diffData;
if (fileL.startsWith('client://') || fileR.startsWith('client://')) {
let textL = '';
let textR = '';
if (fileL.startsWith('client://')) {
const handleL = await resolveClientFileHandle(fileL, false);
if (!handleL) throw new Error('Client left file not found');
const fileObjL = await handleL.getFile();
textL = await fileObjL.text();
} else {
const respL = await fetch(getDownloadUrl(fileL, true));
if (!respL.ok) throw new Error(`Failed to read left file: ${respL.statusText}`);
textL = await respL.text();
}
if (fileR.startsWith('client://')) {
const handleR = await resolveClientFileHandle(fileR, false);
if (!handleR) throw new Error('Client right file not found');
const fileObjR = await handleR.getFile();
textR = await fileObjR.text();
} else {
const respR = await fetch(getDownloadUrl(fileR, true));
if (!respR.ok) throw new Error(`Failed to read right file: ${respR.statusText}`);
textR = await respR.text();
}
diffData = computeJsLineDiff(textL, textR);
diffData.file_left = fileL;
diffData.file_right = fileR;
} else {
const resp = await fetch('/api/tools/diff/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ file_left: fileL, file_right: fileR })
});
if (resp.ok) {
diffData = await resp.json();
} else {
body.innerHTML = `<div style="color: var(--danger); padding: 20px;">Failed to compare files: ${escapeHtml(await resp.text())}</div>`;
return;
}
}
body.innerHTML = `
<div class="diff-file-header-card">
<div class="diff-file-stats">
<div class="diff-path-chip" title="${escapeHtml(sanitizeCredentials(diffData.file_left))}">
<span style="font-weight: 700; color: var(--info);">L:</span>
<code>${escapeHtml(getBasename(diffData.file_left))}</code>
</div>
<span style="color: var(--text-dim); font-size: 11px;">⟷</span>
<div class="diff-path-chip" title="${escapeHtml(sanitizeCredentials(diffData.file_right))}">
<span style="font-weight: 700; color: #c084fc;">R:</span>
<code>${escapeHtml(getBasename(diffData.file_right))}</code>
</div>
<span style="margin-left: 6px; font-size: 10.5px; font-family: var(--font-mono); background: rgba(245, 158, 11, 0.15); color: var(--accent); padding: 2px 6px; border-radius: 3px; font-weight: 700; white-space: nowrap;">
+${diffData.additions} / -${diffData.deletions}
</span>
</div>
<button class="btn btn-sm" onclick="triggerDiff(false)"><i data-lucide="arrow-left"></i> Back</button>
</div>
<div class="diff-container">
${diffData.lines.map(line => `
<div class="diff-line ${line.tag}">
<div class="diff-gutter">${line.line_num_left || ''} | ${line.line_num_right || ''}</div>
<div>${line.tag === 'insert' ? '+ ' : (line.tag === 'delete' ? '- ' : ' ')}${escapeHtml(line.content)}</div>
</div>
`).join('')}
</div>
`;
if (window.lucide) lucide.createIcons();
} catch (e) {
body.innerHTML = `<div style="color: var(--danger); padding: 20px;">Error: ${escapeHtml(String(e))}</div>`;
}
}
// ---------------- CONVERTX FILE & IMAGE CONVERTER TOOL ----------------
let activeConverterFile = '';
let activeConverterPaneIndex = null;
let activeConverterCustomName = false;
let isConvertInProgress = false;
let activeConvertJob = null;
function triggerConvertFile() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || (pane && pane.entries ? pane.entries[pane.cursorIndex] : null);
if (item && !item.is_dir) {
openConverterModal(item.path, null, App.activePaneIndex);
} else {
openConverterModal('', null, App.activePaneIndex);
}
}
let converterDragInitialized = false;
let converterPickerCurrentPath = '/';
function initConverterDrag() {
if (converterDragInitialized) return;
converterDragInitialized = true;
const win = document.getElementById('floating-converter-window');
const header = document.getElementById('converter-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_converter_x');
const savedTop = localStorage.getItem('cd_converter_y');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 300, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 380, Math.max(35, parseInt(savedTop, 10)))}px`;
}
let isDragging = false;
let startX = 0;
let startY = 0;
let initialLeft = 0;
let initialTop = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('button') || e.target.closest('input') || e.target.closest('select')) return;
if (window.innerWidth <= 600) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
bringFloatingWindowToFront(win);
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newLeft = Math.max(0, Math.min(window.innerWidth - 100, initialLeft + dx));
const newTop = Math.max(35, Math.min(window.innerHeight - 100, initialTop + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
if (win.style.left) localStorage.setItem('cd_converter_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_converter_y', parseInt(win.style.top, 10));
}
});
setupConverterDropzone(win);
}
function setupConverterDropzone(win) {
const dropzone = document.getElementById('converter-dropzone');
const targetElements = [win, dropzone].filter(Boolean);
targetElements.forEach(el => {
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (dropzone) dropzone.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
if (!el.contains(e.relatedTarget)) {
if (dropzone) dropzone.classList.remove('drag-over');
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (dropzone) dropzone.classList.remove('drag-over');
// 1. Brum pane drag data
const plainText = e.dataTransfer.getData('text/plain');
if (plainText) {
try {
const data = JSON.parse(plainText);
if (data.paths && data.paths.length > 0) {
setConverterSourceFile(data.paths[0], data.sourcePane);
return;
}
} catch (_) {
if (plainText.startsWith('/') || plainText.match(/^[a-zA-Z]:\\/)) {
setConverterSourceFile(plainText.trim());
return;
}
}
}
// 2. Local OS file drop
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
if (file.path) {
setConverterSourceFile(file.path);
} else {
showToast(`Selected: ${file.name}. Drag files directly from Brum panes or use the file browser.`, 'info');
}
}
});
});
}
function getConverterFileCategoryIcon(filename) {
if (isImageExtension(filename)) return '🖼️';
if (isVideoExtension(filename)) return '🎬';
if (isAudioExtension(filename)) return '🎵';
const lastDot = (filename || '').lastIndexOf('.');
const ext = lastDot !== -1 ? filename.substring(lastDot + 1).toLowerCase() : '';
if (['json', 'yaml', 'yml', 'toml', 'xml', 'csv', 'sql'].includes(ext)) return '📊';
if (['html', 'htm', 'md', 'txt', 'pdf', 'docx'].includes(ext)) return '📄';
return '📦';
}
function setConverterSourceFile(filePath, paneIndex = null) {
if (!filePath) return;
activeConverterFile = filePath;
activeConverterCustomName = false;
if (paneIndex !== null && paneIndex !== undefined) {
activeConverterPaneIndex = paneIndex;
}
const fileName = filePath.split('/').pop() || filePath;
const lastDot = fileName.lastIndexOf('.');
const ext = lastDot !== -1 ? fileName.substring(lastDot + 1).toLowerCase() : '';
const card = document.getElementById('converter-selected-card');
const prompt = document.getElementById('converter-empty-prompt');
const nameEl = document.getElementById('convert-source-filename');
const pathEl = document.getElementById('convert-source-path');
const extBadge = document.getElementById('convert-source-ext-badge');
const iconBadge = document.getElementById('converter-file-icon-badge');
const sizeEl = document.getElementById('convert-source-size');
if (card) card.style.display = 'flex';
if (prompt) prompt.style.display = 'none';
const node = typeof getPaneNode === 'function' ? getPaneNode(activeConverterPaneIndex) : null;
const nodeLabel = (node && node.id !== 'local') ? `[Fleet: ${node.name}] ` : '';
if (nameEl) nameEl.textContent = fileName;
if (pathEl) {
pathEl.textContent = `${nodeLabel}${filePath}`;
pathEl.title = filePath;
}
if (extBadge) extBadge.textContent = ext ? ext.toUpperCase() : 'FILE';
if (iconBadge) iconBadge.textContent = getConverterFileCategoryIcon(fileName);
// Check if size is available in pane
let foundSize = null;
const pane = (activeConverterPaneIndex !== null && activeConverterPaneIndex !== undefined)
? App.panes[activeConverterPaneIndex]
: App.panes[App.activePaneIndex];
if (pane && pane.entries) {
const matched = pane.entries.find(e => e.path === filePath || e.name === fileName);
if (matched && matched.size !== undefined) {
foundSize = formatFileSize(matched.size);
}
}
if (sizeEl) sizeEl.textContent = foundSize ? `Size: ${foundSize}` : '';
// Smart target format selection
const targetFormatEl = document.getElementById('convert-target-format');
if (targetFormatEl) {
if (isVideoExtension(fileName)) {
targetFormatEl.value = 'mp4';
} else if (isAudioExtension(fileName)) {
targetFormatEl.value = 'mp3';
} else if (isImageExtension(fileName)) {
targetFormatEl.value = 'webp';
} else if (['json', 'yaml', 'yml', 'toml'].includes(ext)) {
targetFormatEl.value = ext === 'json' ? 'yaml' : 'json';
}
handleTargetFormatChange(targetFormatEl.value);
}
updateConvertOutputName(true);
if (window.lucide) lucide.createIcons();
}
function clearConverterSourceFile() {
activeConverterFile = '';
activeConverterCustomName = false;
const card = document.getElementById('converter-selected-card');
const prompt = document.getElementById('converter-empty-prompt');
if (card) card.style.display = 'none';
if (prompt) prompt.style.display = 'flex';
const outInput = document.getElementById('convert-output-filename');
if (outInput) outInput.value = '';
const previewEl = document.getElementById('convert-output-preview-path');
if (previewEl) previewEl.textContent = 'Destination: ...';
const statusMsg = document.getElementById('convert-status-msg');
if (statusMsg) statusMsg.style.display = 'none';
if (window.lucide) lucide.createIcons();
}
function useActivePaneItemForConverter() {
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
const item = (pane.selected && pane.selected.size > 0)
? Array.from(pane.selected)[0]
: (pane.entries && pane.entries[pane.cursorIndex] ? pane.entries[pane.cursorIndex].path : null);
if (item) {
setConverterSourceFile(item, App.activePaneIndex);
} else {
showToast('No file selected in active pane', 'warning');
}
}
let universalPickerCallback = null;
function openUniversalFilePicker(options = {}) {
const { title = 'Select File', currentPath, onSelect } = options;
universalPickerCallback = onSelect || null;
const headerTitle = document.querySelector('#converter-file-picker-modal .modal-header span');
if (headerTitle) headerTitle.textContent = title;
const curVal = currentPath
? (currentPath.endsWith('/') ? currentPath : currentPath.substring(0, currentPath.lastIndexOf('/')) || '/')
: (App.panes[App.activePaneIndex]?.path || getUserDefaultHomeDir() || '/');
converterPickerCurrentPath = curVal || '/';
const panesList = document.getElementById('converter-picker-panes-list');
if (panesList) {
panesList.innerHTML = App.panes.map((p, idx) => `
<button type="button" class="btn btn-xs" onclick="navigateConverterPickerPath('${escapeHtml(p.path || '/')}')" title="Pane ${idx + 1} (${p.nodeId || 'local'})">
Pane ${idx + 1}
</button>
`).join('');
}
navigateConverterPickerPath(converterPickerCurrentPath);
showModal('converter-file-picker-modal');
if (window.lucide) lucide.createIcons();
}
function openConverterFilePicker() {
openUniversalFilePicker({
title: 'Select File to Convert',
currentPath: activeConverterFile || App.panes[App.activePaneIndex]?.path,
onSelect: (filePath) => {
setConverterSourceFile(filePath);
const win = document.getElementById('floating-converter-window');
if (win) bringFloatingWindowToFront(win);
}
});
}
function openSplitterFilePicker() {
openUniversalFilePicker({
title: 'Select File to Split',
currentPath: document.getElementById('split-source-path')?.value || App.panes[App.activePaneIndex]?.path,
onSelect: (filePath) => {
setSplitterSourceFile(filePath);
}
});
}
async function navigateConverterPickerPath(newPath) {
if (!newPath) newPath = '/';
converterPickerCurrentPath = newPath;
const pathIn = document.getElementById('converter-picker-path-input');
if (pathIn) pathIn.value = newPath;
const container = document.getElementById('converter-picker-file-list');
if (container) {
container.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--accent);"><i data-lucide="loader" class="spinner"></i> Loading files...</div>';
if (window.lucide) lucide.createIcons();
}
try {
const encoded = encodeURIComponent(newPath);
const resp = await fetch(`/api/fs/list?path=${encoded}&show_hidden=false`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
const entries = (data.entries || []).filter(e => e.name !== '.' && e.name !== '..');
entries.sort((a, b) => {
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
});
if (entries.length === 0) {
if (container) container.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim); font-size: 11px;">Folder is empty.</div>';
} else {
if (container) {
container.innerHTML = entries.map(entry => {
const fullPath = newPath.endsWith('/') ? `${newPath}${entry.name}` : `${newPath}/${entry.name}`;
if (entry.is_dir) {
return `
<div class="converter-file-picker-row" onclick="navigateConverterPickerPath('${escapeHtml(fullPath)}')">
<span style="display: flex; align-items: center; gap: 6px;">
<i data-lucide="folder" style="width: 14px; height: 14px; color: var(--accent);"></i>
<span style="font-weight: 600;">${escapeHtml(entry.name)}</span>
</span>
<span style="font-size: 10px; color: var(--text-dim);">Directory</span>
</div>
`;
} else {
const icon = getConverterFileCategoryIcon(entry.name);
const sizeStr = entry.size !== undefined ? formatFileSize(entry.size) : '';
return `
<div class="converter-file-picker-row" onclick="selectConverterPickerFile('${escapeHtml(fullPath)}')">
<span style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 13px;">${icon}</span>
<span style="font-weight: 500;">${escapeHtml(entry.name)}</span>
</span>
<span style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${sizeStr}</span>
</div>
`;
}
}).join('');
}
}
} else {
if (container) container.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--danger); font-size: 11px;">Cannot open directory: ${escapeHtml(await resp.text())}</div>`;
}
} catch (err) {
if (container) container.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--danger); font-size: 11px;">Network error: ${escapeHtml(String(err))}</div>`;
}
if (window.lucide) lucide.createIcons();
}
function navigateConverterPickerUp() {
const parent = getParentDirectory(converterPickerCurrentPath);
if (parent && parent !== converterPickerCurrentPath) {
navigateConverterPickerPath(parent);
}
}
function selectConverterPickerFile(filePath) {
closeModal('converter-file-picker-modal');
if (typeof universalPickerCallback === 'function') {
const cb = universalPickerCallback;
universalPickerCallback = null;
cb(filePath);
} else {
setConverterSourceFile(filePath);
const win = document.getElementById('floating-converter-window');
if (win) bringFloatingWindowToFront(win);
}
}
function openConverterModal(filePath, defaultFormat = null, paneIndex = null) {
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
activeConverterPaneIndex = resolvedPaneIdx;
const win = document.getElementById('floating-converter-window');
const pill = document.getElementById('convertx-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initConverterDrag();
// If conversion already running for this job, retain state
if (isConvertInProgress && activeConvertJob && (!filePath || filePath === activeConvertJob.filePath)) {
return;
}
if (filePath) {
setConverterSourceFile(filePath, resolvedPaneIdx);
} else {
const pane = App.panes[resolvedPaneIdx];
const item = (pane && pane.selected && pane.selected.size > 0)
? Array.from(pane.selected)[0]
: (pane && pane.entries && pane.entries[pane.cursorIndex] && !pane.entries[pane.cursorIndex].is_dir
? pane.entries[pane.cursorIndex].path
: null);
if (item) {
setConverterSourceFile(item, resolvedPaneIdx);
} else {
clearConverterSourceFile();
}
}
if (defaultFormat) {
setConverterTargetFormat(defaultFormat);
}
const cancelBtn = document.getElementById('btn-convert-cancel');
const bgBtn = document.getElementById('btn-convert-background');
const convertBtn = document.getElementById('btn-run-convert');
const okBtn = document.getElementById('btn-convert-ok');
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
if (bgBtn) bgBtn.style.display = 'none';
if (convertBtn) {
convertBtn.style.display = 'inline-flex';
convertBtn.disabled = false;
}
if (okBtn) okBtn.style.display = 'none';
const statusMsg = document.getElementById('convert-status-msg');
if (statusMsg) statusMsg.style.display = 'none';
if (window.lucide) lucide.createIcons();
}
function minimizeConverterModal() {
const win = document.getElementById('floating-converter-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('convertx-pill');
const titleEl = document.getElementById('convertx-pill-title');
if (isConvertInProgress && activeConvertJob) {
if (titleEl) titleEl.textContent = `Converting ${activeConvertJob.fileName} ➔ ${activeConvertJob.targetFormat.toUpperCase()}`;
if (pill) pill.style.display = 'flex';
showToast('Format Converter running in background', 'info');
} else {
if (pill) pill.style.display = 'none';
}
}
function restoreConverterModal() {
const win = document.getElementById('floating-converter-window');
const pill = document.getElementById('convertx-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
}
function closeConverterModal() {
if (isConvertInProgress) {
minimizeConverterModal();
} else {
const win = document.getElementById('floating-converter-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('convertx-pill');
if (pill) pill.style.display = 'none';
}
}
function setConverterTargetFormat(fmt) {
const targetFormatEl = document.getElementById('convert-target-format');
if (targetFormatEl) {
targetFormatEl.value = fmt;
handleTargetFormatChange(fmt);
}
}
function updateConvertOutputName(forceReset = false) {
if (!activeConverterFile) return;
const fileName = activeConverterFile.split('/').pop() || '';
const lastDot = fileName.lastIndexOf('.');
const stem = lastDot !== -1 ? fileName.substring(0, lastDot) : fileName;
const srcExt = lastDot !== -1 ? fileName.substring(lastDot + 1).toLowerCase() : '';
const targetFmt = (document.getElementById('convert-target-format')?.value || 'webp').toLowerCase().trim();
const outInput = document.getElementById('convert-output-filename');
const previewEl = document.getElementById('convert-output-preview-path');
if (forceReset || !activeConverterCustomName) {
const isSameFormat = srcExt === targetFmt;
const defaultName = isSameFormat ? `${stem}_converted.${targetFmt}` : `${stem}.${targetFmt}`;
if (outInput) outInput.value = defaultName;
}
const currentOutName = outInput ? outInput.value.trim() : `${stem}.${targetFmt}`;
const parentDir = activeConverterFile.substring(0, activeConverterFile.lastIndexOf('/'));
if (previewEl) {
previewEl.textContent = parentDir ? `Destination: ${parentDir}/${currentOutName}` : `Destination: ${currentOutName}`;
}
}
function handleConvertOutputNameInput() {
activeConverterCustomName = true;
updateConvertOutputName(false);
}
function handleTargetFormatChange(fmt) {
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'bmp', 'ico', 'tiff'].includes(fmt.toLowerCase());
const imgOpts = document.getElementById('convert-image-options');
if (imgOpts) imgOpts.style.display = isImage ? 'block' : 'none';
updateConvertOutputName(false);
}
async function executeFileConversion() {
if (!activeConverterFile) {
showToast('Please select a valid file to convert', 'warning');
return;
}
const targetFormat = document.getElementById('convert-target-format')?.value || 'webp';
const quality = parseInt(document.getElementById('convert-quality-slider')?.value || '85', 10);
const resizeW = parseInt(document.getElementById('convert-resize-w')?.value, 10) || null;
const resizeH = parseInt(document.getElementById('convert-resize-h')?.value, 10) || null;
const customOutName = document.getElementById('convert-output-filename')?.value.trim();
let resolvedOutputPath = null;
if (customOutName) {
const parentDir = activeConverterFile.substring(0, activeConverterFile.lastIndexOf('/'));
resolvedOutputPath = parentDir ? `${parentDir}/${customOutName}` : customOutName;
}
isConvertInProgress = true;
const fileName = activeConverterFile.split('/').pop() || activeConverterFile;
activeConvertJob = {
filePath: activeConverterFile,
fileName,
targetFormat
};
const statusMsg = document.getElementById('convert-status-msg');
const cancelBtn = document.getElementById('btn-convert-cancel');
const bgBtn = document.getElementById('btn-convert-background');
const convertBtn = document.getElementById('btn-run-convert');
const okBtn = document.getElementById('btn-convert-ok');
if (bgBtn) bgBtn.style.display = 'inline-flex';
if (statusMsg) {
statusMsg.style.display = 'block';
statusMsg.style.color = 'var(--accent)';
statusMsg.innerHTML = '<i data-lucide="loader"></i> Converting file in progress... <button class="btn btn-xs" onclick="minimizeConverterModal()" style="margin-left: 8px;">Run in Background</button>';
if (window.lucide) lucide.createIcons();
}
if (convertBtn) convertBtn.disabled = true;
try {
const endpoint = (activeConverterPaneIndex !== null && activeConverterPaneIndex !== undefined)
? getPaneEndpoint(activeConverterPaneIndex)
: '';
const headers = (activeConverterPaneIndex !== null && activeConverterPaneIndex !== undefined)
? getPaneAuthHeaders(activeConverterPaneIndex, { 'Content-Type': 'application/json' })
: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` };
const resp = await fetch(`${endpoint}/api/tools/convert`, {
method: 'POST',
headers,
body: JSON.stringify({
source_path: activeConverterFile,
target_format: targetFormat,
output_path: resolvedOutputPath,
quality: quality,
resize_width: resizeW,
resize_height: resizeH
})
});
isConvertInProgress = false;
const pill = document.getElementById('convertx-pill');
if (pill) pill.style.display = 'none';
if (resp.ok) {
const data = await resp.json();
const win = document.getElementById('floating-converter-window');
const isWinOpen = win && win.style.display !== 'none';
if (statusMsg) {
statusMsg.style.display = 'block';
statusMsg.style.color = 'var(--text-main)';
statusMsg.innerHTML = `
<div style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.25); border-radius: 6px; padding: 12px; margin-top: 10px;">
<div style="color: var(--success); font-weight: 700; display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
<i data-lucide="check-circle" style="width: 16px; height: 16px;"></i> Conversion Complete
</div>
<div style="font-size: 11px; line-height: 1.6; color: var(--text-dim);">
<div><strong style="color: var(--text-main);">Output File:</strong> <span style="color: var(--accent); font-family: var(--font-mono); word-break: break-all;">${escapeHtml(data.output_path)}</span></div>
${data.output_size ? `<div><strong style="color: var(--text-main);">Size:</strong> ${formatFileSize(data.output_size)}</div>` : ''}
<div style="margin-top: 4px; color: var(--text-muted);">${escapeHtml(data.message)}</div>
</div>
</div>
`;
if (window.lucide) lucide.createIcons();
}
if (!isWinOpen) {
showToast(`✅ Converted ${fileName} to ${targetFormat.toUpperCase()} (${data.output_size ? formatFileSize(data.output_size) : ''})`, 'success');
}
// Switch buttons to [OK]
if (cancelBtn) cancelBtn.style.display = 'none';
if (bgBtn) bgBtn.style.display = 'none';
if (convertBtn) convertBtn.style.display = 'none';
if (okBtn) okBtn.style.display = 'inline-flex';
refreshAllPanes();
} else {
const errText = await resp.text();
showToast(`Conversion failed: ${errText}`, 'error');
if (statusMsg) {
statusMsg.style.display = 'block';
statusMsg.style.color = 'var(--danger)';
statusMsg.textContent = `Conversion failed: ${errText}`;
}
if (convertBtn) convertBtn.disabled = false;
if (bgBtn) bgBtn.style.display = 'none';
}
} catch (e) {
isConvertInProgress = false;
const pill = document.getElementById('convertx-pill');
if (pill) pill.style.display = 'none';
showToast(`Conversion error: ${e}`, 'error');
if (statusMsg) {
statusMsg.style.display = 'block';
statusMsg.style.color = 'var(--danger)';
statusMsg.textContent = `Error: ${e}`;
}
if (convertBtn) convertBtn.disabled = false;
if (bgBtn) bgBtn.style.display = 'none';
} finally {
activeConvertJob = null;
}
}
// ---------------- USER PROFILE & DROPDOWNS & FAVORITES ----------------
function renderAvatarElement(el, avatar) {
if (!el) return;
const isImage = avatar && (
avatar.startsWith('data:image') ||
avatar.startsWith('http://') ||
avatar.startsWith('https://') ||
avatar.startsWith('/') ||
avatar.startsWith('blob:') ||
avatar.startsWith('assets/') ||
/\.(png|jpe?g|webp|gif|svg|ico)(\?.*)?$/i.test(avatar)
);
if (isImage) {
el.innerHTML = `<img src="${escapeHtml(avatar)}" alt="Avatar" style="width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block;">`;
} else {
el.textContent = avatar || '👤';
}
}
function handleAvatarFileUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('Please select a valid image file (JPEG, PNG, WebP, GIF)', 'warning');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
// Create square canvas with high quality resize
const canvas = document.createElement('canvas');
const targetSize = 160;
canvas.width = targetSize;
canvas.height = targetSize;
const ctx = canvas.getContext('2d');
// Crop to center square
const minDim = Math.min(img.width, img.height);
const startX = (img.width - minDim) / 2;
const startY = (img.height - minDim) / 2;
ctx.drawImage(img, startX, startY, minDim, minDim, 0, 0, targetSize, targetSize);
// Output as compressed WebP
const dataUri = canvas.toDataURL('image/webp', 0.85);
const avatarInput = document.getElementById('profile-input-avatar');
const avatarPreview = document.getElementById('profile-edit-avatar');
if (avatarInput) avatarInput.value = dataUri;
if (avatarPreview) renderAvatarElement(avatarPreview, dataUri);
showToast('Profile photo ready! Click "Save Profile" to apply.', 'info');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function resetAvatarToDefault() {
const avatarInput = document.getElementById('profile-input-avatar');
const avatarPreview = document.getElementById('profile-edit-avatar');
if (avatarInput) avatarInput.value = '👤';
if (avatarPreview) renderAvatarElement(avatarPreview, '👤');
showToast('Avatar reset to default', 'info');
}
function updateHeaderProfile(user) {
if (!user) return;
const localNick = localStorage.getItem('cd_local_nickname');
const localAvatar = localStorage.getItem('cd_local_avatar');
const localEmail = localStorage.getItem('cd_local_email');
const uname = user.nickname || localNick || user.username || 'User';
const roleStr = (user.role || 'ADMIN').toUpperCase();
const avatar = user.avatar_url || localAvatar || '👤';
// Synchronize localStorage with latest profile snapshot
if (user.avatar_url) {
localStorage.setItem('cd_local_avatar', user.avatar_url);
}
try {
localStorage.setItem('cd_user_info', JSON.stringify({
username: user.username,
nickname: uname,
avatar_url: avatar
}));
if (user.username) {
localStorage.setItem('cd_last_username', user.username);
}
} catch (_) {}
const headerLabel = document.getElementById('header-username-label');
const headerBadge = document.getElementById('header-role-badge');
const headerAvatar = document.getElementById('header-avatar-thumb');
if (headerLabel) headerLabel.textContent = uname;
if (headerBadge) headerBadge.textContent = roleStr;
if (headerAvatar) renderAvatarElement(headerAvatar, avatar);
const menuName = document.getElementById('menu-user-name');
const menuEmail = document.getElementById('menu-user-email');
const menuBadge = document.getElementById('menu-role-badge');
const menuAvatar = document.getElementById('menu-avatar-large');
if (menuName) menuName.textContent = uname;
if (menuEmail) menuEmail.textContent = localEmail || user.email || `${user.username || 'user'}@localhost`;
if (menuBadge) menuBadge.textContent = roleStr;
if (menuAvatar) renderAvatarElement(menuAvatar, avatar);
// Synchronize lock screen avatar and name elements
const lockAvatar = document.getElementById('lock-avatar-thumb');
if (lockAvatar) renderAvatarElement(lockAvatar, avatar);
const lockUserText = document.getElementById('lock-username-text');
if (lockUserText) lockUserText.textContent = uname;
// Show/Hide Admin Control Panel item based on role (Admin only)
const isAdmin = user.role === 'admin';
const adminItem = document.getElementById('menu-admin-panel-item');
if (adminItem) {
adminItem.style.display = isAdmin ? 'flex' : 'none';
}
const mobileAdminItem = document.getElementById('mobile-admin-panel-item');
if (mobileAdminItem) {
mobileAdminItem.style.display = isAdmin ? 'flex' : 'none';
}
// Update Logout vs Exit button for standalone desktop mode
updateLogoutOrExitButton();
}
function updateLogoutOrExitButton() {
const isStandalone = App.isStandalone === true || App.config?.server?.standalone === true || window.__TAURI__ !== undefined;
const logoutBtn = document.getElementById('btn-profile-logout');
if (logoutBtn) {
if (isStandalone) {
logoutBtn.title = 'Exit / Quit Brum';
logoutBtn.innerHTML = '<i data-lucide="power" style="width: 14px; height: 14px;"></i> <span id="label-profile-logout">Exit</span>';
} else {
logoutBtn.title = 'Log Out';
logoutBtn.innerHTML = '<i data-lucide="log-out" style="width: 14px; height: 14px;"></i> <span id="label-profile-logout">Log Out</span>';
}
if (window.lucide) lucide.createIcons();
}
}
// ---------------- TOOLS & LAUNCHPAD MENU CUSTOMIZER ----------------
function getToolsMenuConfig() {
try {
const saved = localStorage.getItem('cd_tools_menu_config');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
const result = [];
const seen = new Set();
parsed.forEach(item => {
const def = DEFAULT_TOOLS_MENU.find(d => d.id === item.id);
if (def) {
result.push({ ...def, visible: item.visible !== undefined ? item.visible : def.visible });
seen.add(item.id);
}
});
DEFAULT_TOOLS_MENU.forEach(def => {
if (!seen.has(def.id)) {
result.push({ ...def });
}
});
return result;
}
}
} catch (e) {
console.error('Failed to parse cd_tools_menu_config', e);
}
return DEFAULT_TOOLS_MENU.map(item => ({ ...item }));
}
function saveToolsMenuConfig(config) {
try {
localStorage.setItem('cd_tools_menu_config', JSON.stringify(config));
} catch (e) {
console.error('Failed to save tools menu config', e);
}
renderToolsMenu();
renderToolsSettingsTab();
}
function resetToolsMenuToDefault() {
localStorage.removeItem('cd_tools_menu_config');
renderToolsMenu();
renderToolsSettingsTab();
if (typeof showToast === 'function') {
showToast('Tools & Launchpad menu reset to default order', 'info');
}
}
function getChewtoyIconUrl(p) {
if (!p) return 'assets/amber-frameless-apps.webp';
const icon = p.icon || '';
if (!icon) return 'assets/amber-frameless-apps.webp';
if (icon.startsWith('/') || icon.startsWith('http') || icon.startsWith('assets/')) {
return icon;
}
return `/api/plugins/${encodeURIComponent(p.id)}/assets/${icon}`;
}
function renderToolIconHtml(icon, iconColor = '', size = 18) {
if (!icon) return `<i data-lucide="wrench" style="width:${size}px; height:${size}px; color: var(--accent, #f59e0b);"></i>`;
if (icon.startsWith('/') || icon.startsWith('http') || icon.startsWith('assets/') || icon.endsWith('.png') || icon.endsWith('.webp') || icon.endsWith('.svg')) {
return `<img src="${escapeHtml(icon)}" alt="" style="width: ${size}px; height: ${size}px; object-fit: contain; flex-shrink: 0; vertical-align: middle;" onerror="this.src='assets/amber-frameless-apps.webp'">`;
}
const colorStyle = `color: ${iconColor || 'var(--accent, #f59e0b)'};`;
return `<i data-lucide="${icon}" style="width: ${size}px; height: ${size}px; flex-shrink: 0; ${colorStyle}"></i>`;
}
function renderToolsMenu() {
const menu = document.getElementById('tools-dropdown-menu');
if (!menu) return;
const activeChewToys = (window.installedChewToys || []).filter(p => p.is_enabled !== false && p.enabled !== false);
const activeChewtoyIds = new Set(activeChewToys.map(p => p.id));
const config = getToolsMenuConfig();
const visibleItems = config.filter(item => {
if (item.visible === false) return false;
// Omit legacy built-ins when corresponding decoupled Chewtoy is installed & active
if (item.id === 'calc' && activeChewtoyIds.has('calculator')) return false;
if (item.id === 'tetradog' && (activeChewtoyIds.has('tetrion') || activeChewtoyIds.has('arcade-blocks'))) return false;
return true;
});
let html = '';
visibleItems.forEach(item => {
html += `
<div class="dropdown-item" onclick="${item.action}; closeToolsMenu();">
${renderToolIconHtml(item.icon, item.iconColor, 18)}
<span>${escapeHtml(item.label)}</span>
</div>
`;
});
// Dynamic Installed Chewtoys / Plugins (.grr)
if (activeChewToys.length > 0) {
html += `<div class="dropdown-sep"></div>`;
activeChewToys.forEach(p => {
const iconUrl = getChewtoyIconUrl(p);
html += `
<div class="dropdown-item" onclick="openDynamicChewToy('${escapeHtml(p.id)}'); closeToolsMenu();" style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${renderToolIconHtml(iconUrl, '', 18)}
<span>${escapeHtml(p.name || p.id)}</span>
</div>
<span class="badge" style="font-size: 9px; padding: 1px 5px; background: rgba(245,158,11,0.15); color: var(--accent); flex-shrink: 0; margin-left: 6px;">Chewtoy</span>
</div>
`;
});
}
menu.innerHTML = html;
if (window.lucide) {
lucide.createIcons({ root: menu });
}
}
function openToolsMenuCustomizer(e) {
e?.stopPropagation();
closeToolsMenu();
openSettingsModal();
switchSettingsTab('tab-tools');
}
let draggedToolIndex = null;
function renderToolsSettingsTab() {
const container = document.getElementById('settings-tools-list');
const counter = document.getElementById('tools-visible-counter');
if (!container) return;
const activeChewToys = (window.installedChewToys || []).filter(p => p.is_enabled !== false && p.enabled !== false);
const activeChewtoyIds = new Set(activeChewToys.map(p => p.id));
const config = getToolsMenuConfig().filter(item => {
if (item.id === 'calc' && activeChewtoyIds.has('calculator')) return false;
if (item.id === 'tetradog' && (activeChewtoyIds.has('tetrion') || activeChewtoyIds.has('arcade-blocks'))) return false;
return true;
});
const visibleCount = config.filter(item => item.visible !== false).length;
if (counter) {
counter.textContent = `${visibleCount} / ${config.length} Visible`;
}
container.innerHTML = config.map((item, idx) => {
const isFirst = idx === 0;
const isLast = idx === config.length - 1;
const isVisible = item.visible !== false;
return `
<div class="tool-reorder-row ${!isVisible ? 'is-hidden' : ''}"
draggable="true"
data-tool-id="${item.id}"
data-index="${idx}"
ondragstart="handleToolDragStart(event, ${idx})"
ondragover="handleToolDragOver(event, ${idx})"
ondragleave="handleToolDragLeave(event)"
ondrop="handleToolDrop(event, ${idx})"
ondragend="handleToolDragEnd(event)">
<!-- Left: Drag handle, checkbox, icon, label & description -->
<div style="display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1;">
<span class="tool-drag-handle" title="Drag up or down to reorder">⋮⋮</span>
<input type="checkbox" ${isVisible ? 'checked' : ''} onchange="toggleToolVisibility('${item.id}', this.checked)" title="Toggle Visibility in Tools Menu" style="cursor: pointer; width: 15px; height: 15px; accent-color: var(--accent);">
<span style="display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0;">
${renderToolIconHtml(item.icon, item.iconColor, 18)}
</span>
<div style="display: flex; flex-direction: column; min-width: 0; flex: 1;">
<div style="font-weight: 600; font-size: 12px; color: ${isVisible ? 'var(--text-main)' : 'var(--text-muted)'}; display: flex; align-items: center; gap: 6px;">
<span>${escapeHtml(item.label)}</span>
${item.icon.includes('.webp') || item.icon.includes('.png') ? '<span class="badge" style="font-size: 9px; padding: 1px 4px; background: rgba(245,158,11,0.15); color: var(--accent);">ChewToy</span>' : ''}
</div>
${item.desc ? `<div style="font-size: 10px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(item.desc)}</div>` : ''}
</div>
</div>
<!-- Right: ChewToy standard 26px action controls (Top, Up, Down, Bottom) -->
<div style="display: flex; align-items: center; gap: 3px; flex-shrink: 0; margin-left: 10px;">
<button class="btn btn-icon btn-sm" onclick="moveToolItem('${item.id}', 'top')" ${isFirst ? 'disabled style="opacity:0.3;"' : ''} title="Move to Top (⤒)" style="width: 26px; height: 26px; padding: 0;">
<i data-lucide="arrow-up-to-line" style="width: 13px; height: 13px;"></i>
</button>
<button class="btn btn-icon btn-sm" onclick="moveToolItem('${item.id}', 'up')" ${isFirst ? 'disabled style="opacity:0.3;"' : ''} title="Move Up (▲)" style="width: 26px; height: 26px; padding: 0;">
<i data-lucide="arrow-up" style="width: 13px; height: 13px;"></i>
</button>
<button class="btn btn-icon btn-sm" onclick="moveToolItem('${item.id}', 'down')" ${isLast ? 'disabled style="opacity:0.3;"' : ''} title="Move Down (▼)" style="width: 26px; height: 26px; padding: 0;">
<i data-lucide="arrow-down" style="width: 13px; height: 13px;"></i>
</button>
<button class="btn btn-icon btn-sm" onclick="moveToolItem('${item.id}', 'bottom')" ${isLast ? 'disabled style="opacity:0.3;"' : ''} title="Move to Bottom (⤓)" style="width: 26px; height: 26px; padding: 0;">
<i data-lucide="arrow-down-to-line" style="width: 13px; height: 13px;"></i>
</button>
</div>
</div>
`;
}).join('');
if (window.lucide) {
lucide.createIcons({ root: container });
}
}
function handleToolDragStart(e, index) {
draggedToolIndex = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
e.currentTarget.classList.add('dragging');
}
function handleToolDragOver(e, index) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const row = e.currentTarget;
if (row && !row.classList.contains('drag-over') && draggedToolIndex !== index) {
row.classList.add('drag-over');
}
}
function handleToolDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
function handleToolDrop(e, targetIndex) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (draggedToolIndex === null || draggedToolIndex === targetIndex) return;
const config = getToolsMenuConfig();
const movedItem = config.splice(draggedToolIndex, 1)[0];
config.splice(targetIndex, 0, movedItem);
saveToolsMenuConfig(config);
}
function handleToolDragEnd(e) {
draggedToolIndex = null;
document.querySelectorAll('.tool-reorder-row').forEach(el => {
el.classList.remove('dragging');
el.classList.remove('drag-over');
});
}
function moveToolItem(id, direction) {
const config = getToolsMenuConfig();
const idx = config.findIndex(item => item.id === id);
if (idx === -1) return;
if (direction === 'up' && idx > 0) {
const temp = config[idx];
config[idx] = config[idx - 1];
config[idx - 1] = temp;
} else if (direction === 'down' && idx < config.length - 1) {
const temp = config[idx];
config[idx] = config[idx + 1];
config[idx + 1] = temp;
} else if (direction === 'top' && idx > 0) {
const [item] = config.splice(idx, 1);
config.unshift(item);
} else if (direction === 'bottom' && idx < config.length - 1) {
const [item] = config.splice(idx, 1);
config.push(item);
}
saveToolsMenuConfig(config);
}
function toggleToolVisibility(id, visible) {
const config = getToolsMenuConfig();
const item = config.find(i => i.id === id);
if (item) {
item.visible = visible;
saveToolsMenuConfig(config);
}
}
function toggleToolsMenu(e) {
e?.stopPropagation();
const menu = document.getElementById('tools-dropdown-menu');
const profileMenu = document.getElementById('profile-dropdown-menu');
if (typeof closeFleetSwitcherDropdown === 'function') closeFleetSwitcherDropdown();
if (profileMenu) profileMenu.classList.remove('active');
if (menu) {
renderToolsMenu();
menu.classList.toggle('active');
}
}
function closeToolsMenu() {
const menu = document.getElementById('tools-dropdown-menu');
if (menu) menu.classList.remove('active');
}
function toggleProfileMenu(e) {
e?.stopPropagation();
const menu = document.getElementById('profile-dropdown-menu');
const toolsMenu = document.getElementById('tools-dropdown-menu');
if (typeof closeFleetSwitcherDropdown === 'function') closeFleetSwitcherDropdown();
if (toolsMenu) toolsMenu.classList.remove('active');
if (menu) menu.classList.toggle('active');
}
function closeProfileMenu() {
const menu = document.getElementById('profile-dropdown-menu');
if (menu) menu.classList.remove('active');
}
function toggleEditorActionsDropdown(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('editor-actions-dropdown-menu');
if (!menu) return;
const isShown = menu.style.display === 'block';
menu.style.display = isShown ? 'none' : 'block';
if (!isShown && window.lucide) {
lucide.createIcons({ root: menu });
}
}
function closeEditorActionsDropdown() {
const menu = document.getElementById('editor-actions-dropdown-menu');
if (menu) menu.style.display = 'none';
}
function toggleImgTransformDropdown(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('img-transform-dropdown-menu');
if (!menu) return;
const isShown = menu.style.display === 'block';
menu.style.display = isShown ? 'none' : 'block';
}
function closeImgTransformDropdown() {
const menu = document.getElementById('img-transform-dropdown-menu');
if (menu) menu.style.display = 'none';
}
// ---------------- GLOBAL CAPTURE-PHASE DISMISS FOR ALL DROPDOWNS & MENUS ----------------
function dismissGlobalPopoversAndMenus(e) {
const target = e.target;
if (!target) return;
// 1. Places & Fleet Hub popup
const favMenu = document.getElementById('pane-favorites-popup');
if (favMenu && !target.closest('#pane-favorites-popup') && !target.closest('[id^="btn-favorites-"]') && !target.closest('.pane-node-chip')) {
favMenu.remove();
}
// 2. Pane Actions / Tools popup
const toolsMenu = document.getElementById('pane-tools-popup');
if (toolsMenu && !target.closest('#pane-tools-popup') && !target.closest('[id^="pane-tools-btn-"]')) {
toolsMenu.remove();
}
// 3. Pane Transfer & Ingest Hub popup
const transferMenu = document.getElementById('pane-transfer-popup');
if (transferMenu && !target.closest('#pane-transfer-popup') && !target.closest('.pane-transfer-wrapper') && !target.closest('[id^="btn-transfer-"]')) {
transferMenu.remove();
}
// 4. Pane Upload & Download popup
const uploadMenu = document.getElementById('pane-upload-popup');
if (uploadMenu && !target.closest('#pane-upload-popup') && !target.closest('[id^="btn-upload-"]')) {
uploadMenu.remove();
}
// 5. Pane Settings & Two-Tone preset popup
const settingsMenu = document.getElementById('pane-settings-popup');
if (settingsMenu && !target.closest('#pane-settings-popup') && !target.closest('[id^="pane-badge-"]') && !target.closest('.mobile-pane-tab')) {
settingsMenu.remove();
}
// 5. Column Chooser popover
const colChooser = document.getElementById('col-chooser-popover');
if (colChooser && !target.closest('#col-chooser-popover') && !target.closest('.pane-col-config-btn')) {
colChooser.remove();
}
// 6. Breadcrumb Subfolder & Fleet Node popovers
const breadcrumbPopovers = document.querySelectorAll('.breadcrumb-popover');
if (breadcrumbPopovers.length > 0 && !target.closest('.breadcrumb-popover') && !target.closest('.crumb-sep-dropdown') && !target.closest('.pane-node-chip') && !target.closest('.pane-node-btn')) {
breadcrumbPopovers.forEach(p => p.remove());
}
// 6. Header Tools Launchpad dropdown
const headerTools = document.getElementById('tools-dropdown-menu');
if (headerTools && headerTools.classList.contains('active')) {
if (!target.closest('#btn-tools-menu') && !target.closest('#tools-dropdown-menu')) {
headerTools.classList.remove('active');
}
}
// 7. Header User Profile dropdown
const headerProfile = document.getElementById('profile-dropdown-menu');
if (headerProfile && headerProfile.classList.contains('active')) {
if (!target.closest('#btn-user-profile') && !target.closest('#profile-dropdown-menu')) {
headerProfile.classList.remove('active');
}
}
// 8. Fleet Cluster Switcher dropdown
const fleetDropdown = document.getElementById('fleet-switcher-dropdown');
if (fleetDropdown && fleetDropdown.style.display !== 'none' && !target.closest('#fleet-switcher-wrapper')) {
if (typeof closeFleetSwitcherDropdown === 'function') closeFleetSwitcherDropdown();
else fleetDropdown.style.display = 'none';
}
// 9. Editor Actions & Img Transform touch menus
const editorTouchMenu = document.getElementById('editor-actions-dropdown-menu');
if (editorTouchMenu && editorTouchMenu.style.display === 'block' && !target.closest('#editor-actions-dropdown-container')) {
editorTouchMenu.style.display = 'none';
}
const imgTouchMenu = document.getElementById('img-transform-dropdown-menu');
if (imgTouchMenu && imgTouchMenu.style.display === 'block' && !target.closest('#img-transform-dropdown-container')) {
imgTouchMenu.style.display = 'none';
}
// 10. MediaPlayer Subtitles dropdown
const subsDropdown = document.getElementById('mediaplayer-subtitles-dropdown');
if (subsDropdown && subsDropdown.style.display === 'block' && !target.closest('.mediaplayer-dropdown') && !target.closest('#btn-mediaplayer-subtitles')) {
subsDropdown.style.display = 'none';
}
}
// Register in capture phase so outside clicks/touches dismiss popups reliably even when child elements call stopPropagation()
document.addEventListener('pointerdown', dismissGlobalPopoversAndMenus, true);
function openAboutModal() {
document.getElementById('profile-dropdown-menu')?.classList.remove('active');
document.getElementById('tools-dropdown-menu')?.classList.remove('active');
hideContextMenu();
updateAboutModalContent();
showModal('about-modal');
const modalEl = document.getElementById('about-modal');
if (modalEl) {
modalEl.style.zIndex = '3500';
modalEl.classList.add('active');
}
}
window.openAbout = openAboutModal;
window.showAbout = openAboutModal;
window.openAboutModal = openAboutModal;
function updateAboutModalContent() {
const ver = App.version || (App.systemStatus && App.systemStatus.version) || '0.8.3';
document.querySelectorAll('#about-version-badge, .about-version-badge').forEach(b => {
b.textContent = `v${ver} (Desktop & Web)`;
});
if (!App.systemStatus) {
fetch('/api/system/status')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
App.systemStatus = data;
if (data.version) applyAppVersion(data.version);
updateAboutSystemDetails(data);
}
})
.catch(() => {});
} else {
updateAboutSystemDetails(App.systemStatus);
}
}
function updateAboutSystemDetails(status) {
if (!status) return;
document.querySelectorAll('#about-sys-os, .about-sys-os').forEach(el => {
el.textContent = `${status.os || 'Linux'} (${status.hostname || 'localhost'})`;
});
document.querySelectorAll('#about-sys-mode, .about-sys-mode').forEach(el => {
el.textContent = status.standalone ? 'Desktop Standalone (Native / Hyprland)' : (status.auth_enabled ? 'Multi-User Server (PAM + SQLite)' : 'Standalone Web Mode');
});
}
function openUserProfileModal() {
document.getElementById('profile-dropdown-menu')?.classList.remove('active');
const user = App.user || {};
const editAvatar = document.getElementById('profile-edit-avatar');
const editUname = document.getElementById('profile-edit-username');
const editBadge = document.getElementById('profile-edit-role-badge');
const editAuthType = document.getElementById('profile-edit-auth-type');
const localNick = localStorage.getItem('cd_local_nickname');
const localAvatar = localStorage.getItem('cd_local_avatar');
const localEmail = localStorage.getItem('cd_local_email');
if (editAvatar) renderAvatarElement(editAvatar, user.avatar_url || localAvatar || '👤');
if (editUname) editUname.textContent = user.nickname || localNick || user.username || 'User';
if (editBadge) editBadge.textContent = (user.role || 'ADMIN').toUpperCase();
if (editAuthType) editAuthType.textContent = (App.isStandalone || !App.token) ? 'Local Standalone Mode' : (user.is_pam ? 'PAM / Local Linux Account' : 'Internal Database Account');
const nickInput = document.getElementById('profile-input-nickname');
const emailInput = document.getElementById('profile-input-email');
const avatarInput = document.getElementById('profile-input-avatar');
const passInput = document.getElementById('profile-input-password');
if (nickInput) nickInput.value = user.nickname || localNick || '';
if (emailInput) emailInput.value = user.email || localEmail || '';
if (avatarInput) avatarInput.value = user.avatar_url || localAvatar || '';
if (passInput) passInput.value = '';
const passSection = document.getElementById('profile-password-section');
if (passSection) passSection.style.display = (App.isStandalone || !App.token) ? 'none' : 'block';
const statusMsg = document.getElementById('profile-status-msg');
if (statusMsg) statusMsg.style.display = 'none';
if (window.lucide) lucide.createIcons();
showModal('profile-modal');
}
async function saveUserProfile() {
const nickname = document.getElementById('profile-input-nickname')?.value.trim() || null;
const email = document.getElementById('profile-input-email')?.value.trim() || null;
const avatar_url = document.getElementById('profile-input-avatar')?.value.trim() || null;
const new_password = document.getElementById('profile-input-password')?.value || null;
const statusMsg = document.getElementById('profile-status-msg');
if (statusMsg) {
statusMsg.style.display = 'block';
statusMsg.style.color = 'var(--accent)';
statusMsg.textContent = 'Saving profile...';
}
// Persist locally in localStorage for standalone / immediate UI feedback
if (nickname) localStorage.setItem('cd_local_nickname', nickname);
else localStorage.removeItem('cd_local_nickname');
if (avatar_url) localStorage.setItem('cd_local_avatar', avatar_url);
else localStorage.removeItem('cd_local_avatar');
if (email) localStorage.setItem('cd_local_email', email);
else localStorage.removeItem('cd_local_email');
if (!App.user) App.user = {};
if (nickname) App.user.nickname = nickname;
if (avatar_url) App.user.avatar_url = avatar_url;
if (email) App.user.email = email;
updateHeaderProfile(App.user);
try {
localStorage.setItem('cd_user_info', JSON.stringify({
username: App.user.username,
nickname: nickname || App.user.nickname,
avatar_url: avatar_url || App.user.avatar_url
}));
} catch (_) {}
const lockAvatar = document.getElementById('lock-avatar-thumb');
if (lockAvatar) renderAvatarElement(lockAvatar, avatar_url || App.user.avatar_url || '👤');
const lockUserText = document.getElementById('lock-username-text');
if (lockUserText) lockUserText.textContent = nickname || App.user.nickname || App.user.username || 'Brum User';
try {
const headers = { 'Content-Type': 'application/json' };
if (App.token) {
headers['Authorization'] = `Bearer ${App.token}`;
}
const resp = await fetch('/api/auth/profile', {
method: 'POST',
headers,
body: JSON.stringify({ nickname, email, avatar_url, new_password })
});
if (resp.ok) {
if (statusMsg) {
statusMsg.style.color = 'var(--success)';
statusMsg.textContent = 'Profile updated successfully!';
}
// Re-fetch me
const meHeaders = {};
if (App.token) meHeaders['Authorization'] = `Bearer ${App.token}`;
const meResp = await fetch('/api/auth/me', { headers: meHeaders });
if (meResp.ok) {
App.user = await meResp.json();
if (avatar_url && !App.user.avatar_url) App.user.avatar_url = avatar_url;
if (nickname && !App.user.nickname) App.user.nickname = nickname;
updateHeaderProfile(App.user);
try {
localStorage.setItem('cd_user_info', JSON.stringify({
username: App.user.username,
nickname: nickname || App.user.nickname,
avatar_url: avatar_url || App.user.avatar_url
}));
} catch (_) {}
}
setTimeout(() => closeModal('profile-modal'), 800);
} else {
if (statusMsg) {
statusMsg.style.color = 'var(--danger)';
statusMsg.textContent = `Save failed: ${await resp.text()}`;
}
}
} catch (e) {
if (statusMsg) {
statusMsg.style.color = 'var(--success)';
statusMsg.textContent = 'Profile saved locally!';
}
setTimeout(() => closeModal('profile-modal'), 800);
}
}
function openAdminPanel() {
document.getElementById('profile-dropdown-menu')?.classList.remove('active');
if (App.user?.role !== 'admin') {
showToast('Access restricted to Administrators.', 'warning');
return;
}
updateStandaloneUI();
showModal('admin-panel-modal');
switchAdminTab('admin-tab-users');
}
function openBookmarksManager() {
document.getElementById('pane-favorites-popup')?.remove();
document.getElementById('pane-tools-popup')?.remove();
document.getElementById('pane-upload-popup')?.remove();
document.getElementById('pane-transfer-popup')?.remove();
openSettingsModal();
switchSettingsTab('tab-bookmarks');
}
async function openPaneFavoritesMenu(e, paneIndex) {
if (e && e.stopPropagation) e.stopPropagation();
const existing = document.getElementById('pane-favorites-popup');
const wasOpenForThisPane = existing && existing.dataset.paneIndex === String(paneIndex);
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
if (wasOpenForThisPane) return;
let globalMounts = [];
let userBookmarks = [];
let storageRoots = [];
let trashSummary = { total_items: 0, total_size: 0, files_dir: '' };
try {
const [mountsRes, bmRes, rootsRes, trashRes, disksRes] = await Promise.all([
fetch('/api/mounts/accessible', { headers: { 'Authorization': `Bearer ${App.token}` } }),
fetch('/api/bookmarks', { headers: { 'Authorization': `Bearer ${App.token}` } }),
fetch('/api/storage/roots', { headers: { 'Authorization': `Bearer ${App.token}` } }),
fetch('/api/tools/trash/summary', { headers: { 'Authorization': `Bearer ${App.token}` } }).catch(() => null),
(!window._systemDisks || window._systemDisks.length === 0) ? fetch('/api/tools/disks', { headers: { 'Authorization': `Bearer ${App.token}` } }).catch(() => null) : Promise.resolve(null)
]);
if (mountsRes.ok) globalMounts = await mountsRes.json();
if (bmRes.ok) userBookmarks = await bmRes.json();
if (rootsRes.ok) storageRoots = await rootsRes.json();
if (trashRes && trashRes.ok) trashSummary = await trashRes.json();
if (disksRes && disksRes.ok) {
window._systemDisks = await disksRes.json() || [];
}
} catch (err) {
console.warn('Failed to load favorites/bookmarks/storage roots:', err);
}
const protoIcons = {
'local': 'folder',
'web': 'globe',
'smb': 'share-2',
'nfs': 'server',
's3': 'cloud',
'sftp': 'terminal',
'webdav': 'globe',
'hetzner-box': 'box',
'proton': 'shield'
};
const curPanePath = App.panes[paneIndex]?.path || '/';
const fleetNodes = typeof getAllFleetNodes === 'function' ? getAllFleetNodes() : [];
const currentPane = App.panes && App.panes[paneIndex];
const currentNodeId = currentPane ? (currentPane.nodeId || 'local') : 'local';
const drives = storageRoots.filter(r => r.path.match(/^[a-zA-Z]:[\\/]/));
const nonDriveRoots = storageRoots.filter(r => !r.path.match(/^[a-zA-Z]:[\\/]/));
const popup = document.createElement('div');
popup.id = 'pane-favorites-popup';
popup.dataset.paneIndex = String(paneIndex);
popup.className = 'pane-favorites-dropdown active';
popup.innerHTML = `
<div style="padding: 8px 12px; font-weight: 700; font-size: 11px; color: var(--accent); background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<span style="display: flex; align-items: center; gap: 6px;"><i data-lucide="paw-print" style="width: 14px; height: 14px;"></i> Places & Fleet</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 10px; color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="document.getElementById('pane-favorites-popup')?.remove(); openBookmarksManager();">Bookmarks</span>
<span style="font-size: 11px; color: var(--text-dim); cursor: pointer;" onclick="document.getElementById('pane-favorites-popup')?.remove();">✕</span>
</div>
</div>
<div class="pane-dropdown-body" style="padding: 4px 0;">
${curPanePath.includes('://') ? `
<div class="dropdown-item" data-action="disconnect-remote" data-pane="${paneIndex}" style="color: var(--danger, #ef4444); background: rgba(239,68,68,0.08);">
<i data-lucide="log-out" style="color: var(--danger, #ef4444);"></i>
<div>
<div style="font-weight: 700;">Disconnect Remote Connection</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(sanitizeCredentials(curPanePath))}</div>
</div>
</div>
<div class="context-sep" style="margin: 4px 0;"></div>
` : ''}
${drives.length > 0 ? `
<div style="padding: 4px 12px; font-size: 10px; color: var(--accent); font-weight: 700; text-transform: uppercase;">Drives & Partitions</div>
${drives.map(d => {
const isAct = curPanePath.toUpperCase().startsWith(d.path.toUpperCase());
const diskInfo = (window._systemDisks || []).find(it => it.mount_point.toUpperCase().startsWith(d.path.toUpperCase()));
const quotaBadge = diskInfo ? `<span style="color: ${diskInfo.usage_percentage > 90 ? '#ef4444' : '#10b981'}; font-size: 9.5px; font-family: var(--font-mono);">${diskInfo.formatted_available} free</span>` : '';
return `
<div class="dropdown-item ${isAct ? 'active' : ''}" data-action="load-dir" data-pane="${paneIndex}" data-path="${escapeHtml(d.path)}">
<span style="font-size: 13px;">🪟</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>${escapeHtml(d.path)} <span style="font-size: 10px; color: var(--text-dim); font-weight: normal;">(${escapeHtml(d.name.replace(/^Local Disk\s*\(/i, '').replace(/\)$/, ''))})</span></span>
${quotaBadge}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(d.path)}</div>
</div>
${isAct ? '<span style="color: var(--accent); font-size: 11px; margin-left: 4px;">✓</span>' : ''}
</div>
`;
}).join('')}
<div class="context-sep" style="margin: 4px 0;"></div>
` : ''}
<div style="padding: 4px 12px; font-size: 10px; color: var(--accent); font-weight: 700; text-transform: uppercase;">Places & Storage Roots</div>
${nonDriveRoots.map(r => {
const displayName = (r.id === 'home' || r.name.startsWith('Personal Home')) ? 'Home' : r.name;
const diskInfo = (window._systemDisks || []).find(it => it.mount_point === r.path || (r.path === '/' && it.mount_point === '/'));
const quotaBadge = diskInfo ? `<span style="color: ${diskInfo.usage_percentage > 90 ? '#ef4444' : '#10b981'}; font-size: 9.5px; font-family: var(--font-mono);">${diskInfo.formatted_available} free</span>` : '';
const isAct = curPanePath.toUpperCase() === r.path.toUpperCase()
|| curPanePath.replace(/\\/g, '/').toUpperCase() === r.path.replace(/\\/g, '/').toUpperCase()
|| (r.id === 'home' && (curPanePath.startsWith(r.path) || curPanePath.replace(/\\/g, '/').toUpperCase().startsWith(r.path.replace(/\\/g, '/').toUpperCase())));
const iconName = r.id === 'home'
? 'home'
: (r.id === 'system-root'
? 'hard-drive'
: (r.path.startsWith('\\\\') || r.path.startsWith('//') || r.path.startsWith('smb://') ? 'share-2' : 'folder'));
return `
<div class="dropdown-item ${isAct ? 'active' : ''}" data-action="load-dir" data-pane="${paneIndex}" data-path="${escapeHtml(r.path)}">
<i data-lucide="${iconName}"></i>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 6px;">
<span>${escapeHtml(displayName)}</span>
${r.read_only ? '<span class="badge" style="font-size: 8px; padding: 1px 4px; background: rgba(239,68,68,0.2); color: var(--danger);">READ ONLY</span>' : ''}
</div>
${quotaBadge}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(r.path)}</div>
</div>
${isAct ? '<span style="color: var(--accent); font-size: 11px; margin-left: 4px;">✓</span>' : ''}
</div>
`;
}).join('')}
<div class="dropdown-item ${isTrashDirectory(curPanePath) ? 'active' : ''}" data-action="load-dir" data-pane="${paneIndex}" data-path="trash://">
<i data-lucide="trash-2" style="color: ${trashSummary.total_items > 0 ? 'var(--accent)' : 'var(--text-dim)'};"></i>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>${escapeHtml(getTrashDisplayName(true))}</span>
${trashSummary.total_items > 0 ? `<span class="badge" style="font-size: 8.5px; padding: 1px 5px; background: rgba(245, 158, 11, 0.15); color: var(--accent);">${trashSummary.total_items} item${trashSummary.total_items > 1 ? 's' : ''} · ${formatBytes(trashSummary.total_size)}</span>` : '<span style="font-size: 9px; color: var(--text-dim);">Empty</span>'}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(trashSummary.files_dir || (isWindowsHost() ? 'Windows Recycle Bin ($Recycle.Bin)' : '~/.local/share/Trash/files'))}</div>
</div>
${isTrashDirectory(curPanePath) ? '<span style="color: var(--accent); font-size: 11px; margin-left: 4px;">✓</span>' : ''}
</div>
<div class="context-sep" style="margin: 4px 0;"></div>
<!-- Fleet Control Nodes -->
<div style="padding: 4px 12px; font-size: 10px; color: var(--accent); font-weight: 700; text-transform: uppercase; display: flex; justify-content: space-between; align-items: center;">
<span>Fleet Control</span>
<span style="font-size: 9px; opacity: 0.8; cursor: pointer; text-decoration: underline;" onclick="document.getElementById('pane-favorites-popup')?.remove(); openFleetManagerModal();">Manage</span>
</div>
<div class="dropdown-item ${currentNodeId === 'local' ? 'active' : ''}" data-action="switch-node" data-pane="${paneIndex}" data-node-id="local" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
<span class="pane-node-dot" style="background: #10b981; width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;"></span>
<div style="min-width: 0; flex: 1;">
<div style="font-weight: 600; font-size: 12px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(getLocalHostDisplayName())}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Local Brum</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<span class="fleet-tag-pill" style="font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; background: rgba(16, 185, 129, 0.12); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.25);" title="Local coordinator: v${escapeHtml(App.version || '1.1.2')}">v${escapeHtml(App.version || '1.1.2')}</span>
<span style="font-size: 9.5px; color: var(--text-dim); font-family: var(--font-mono); background: rgba(255,255,255,0.05); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; white-space: nowrap;">0 ms</span>
${currentNodeId === 'local' ? '<span style="color: var(--accent); font-size: 12px; font-weight: 700; width: 12px; text-align: center;">✓</span>' : '<span style="width: 12px;"></span>'}
</div>
</div>
${fleetNodes.map(n => {
const isAct = currentNodeId === n.id;
let dotColor = '#94a3b8';
if (n.status === 'online') dotColor = '#10b981';
else if (n.status === 'unauthorized') dotColor = '#f59e0b';
else if (n.status === 'offline') dotColor = '#ef4444';
const latencyStr = typeof n.latency_ms === 'number' ? `${n.latency_ms} ms` : (n.status || 'unknown');
const cleanUrl = (n.endpoint_url || '').replace(/^https?:\/\//, '');
return `
<div class="dropdown-item ${isAct ? 'active' : ''}" data-action="switch-node" data-pane="${paneIndex}" data-node-id="${escapeHtml(n.id)}" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
<span class="pane-node-dot" style="background: ${dotColor}; width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;"></span>
<div style="min-width: 0; flex: 1;">
<div style="font-weight: 600; font-size: 12px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(n.name)}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${escapeHtml(cleanUrl)}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<span style="font-size: 9.5px; color: var(--text-dim); font-family: var(--font-mono); background: rgba(255,255,255,0.05); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; white-space: nowrap;">
${escapeHtml(latencyStr)}
</span>
${isAct ? '<span style="color: var(--accent); font-size: 12px; font-weight: 700; width: 12px; text-align: center;">✓</span>' : '<span style="width: 12px;"></span>'}
</div>
</div>
`;
}).join('')}
<div class="context-sep" style="margin: 4px 0;"></div>
${userBookmarks.length > 0 ? `
<div style="padding: 4px 12px; font-size: 10px; color: var(--accent); font-weight: 700; text-transform: uppercase;">Saved Bookmarks</div>
${userBookmarks.map(b => `
<div class="dropdown-item" data-action="bookmark" data-bpath="${encodeURIComponent(b.path)}" data-bpass="${b.has_password}" data-bproto="${escapeHtml(b.protocol)}">
<i data-lucide="${protoIcons[b.protocol] || 'bookmark'}" style="color: var(--accent);"></i>
<div>
<div style="font-weight: 600; display: flex; align-items: center; gap: 6px;">
<span>${escapeHtml(b.name)}</span>
<span class="badge" style="font-size: 8.5px; padding: 1px 4px; text-transform: uppercase;">${escapeHtml(b.protocol)}</span>
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(sanitizeCredentials(b.path))}</div>
</div>
</div>
`).join('')}
<div class="context-sep" style="margin: 4px 0;"></div>
` : ''}
${globalMounts.length > 0 ? `
<div style="padding: 4px 12px; font-size: 10px; color: var(--accent); font-weight: 700; text-transform: uppercase; display: flex; justify-content: space-between;">
<span>Remote VFS Shares</span>
<span style="font-size: 9px; opacity: 0.8;">ADMIN</span>
</div>
${globalMounts.map(m => `
<div class="dropdown-item" data-action="bookmark" data-bpath="${encodeURIComponent(m.target_uri)}" data-bpass="false" data-bproto="${escapeHtml(m.protocol)}">
<i data-lucide="${protoIcons[m.protocol] || 'network'}" style="color: var(--accent);"></i>
<div>
<div style="font-weight: 600; display: flex; align-items: center; gap: 6px;">
<span>${escapeHtml(m.name)}</span>
<span class="badge" style="font-size: 8.5px; padding: 1px 4px; text-transform: uppercase;">${escapeHtml(m.protocol)}</span>
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">${escapeHtml(sanitizeCredentials(m.target_uri))}</div>
</div>
</div>
`).join('')}
<div class="context-sep" style="margin: 4px 0;"></div>
` : ''}
<div class="dropdown-item" data-action="add-bookmark" data-pane="${paneIndex}" data-path="${encodeURIComponent(curPanePath)}" style="color: var(--accent);">
<i data-lucide="bookmark-plus"></i>
<div style="font-weight: 600;">+ Bookmark Current Folder</div>
</div>
<div class="dropdown-item" data-action="open-remote-modal" data-pane="${paneIndex}" style="color: var(--text-muted);">
<i data-lucide="network"></i>
<div style="font-weight: 500;">+ Connect Remote Storage (SFTP/SMB)...</div>
</div>
<div class="dropdown-item" data-action="open-fleet-modal" style="color: var(--text-muted);">
<i data-lucide="server"></i>
<div style="font-weight: 500;">+ Fleet Control...</div>
</div>
</div>
`;
popup.addEventListener('click', (ev) => {
const item = ev.target.closest('[data-action]');
if (!item) return;
const action = item.dataset.action;
const targetPath = item.dataset.path;
const targetPane = parseInt(item.dataset.pane || String(paneIndex), 10);
if (action === 'load-dir') {
loadPaneDirectory(targetPane, targetPath);
popup.remove();
} else if (action === 'switch-node') {
const nodeId = item.dataset.nodeId;
switchPaneNode(targetPane, nodeId);
popup.remove();
} else if (action === 'disconnect-remote') {
disconnectPaneRemote(targetPane);
popup.remove();
} else if (action === 'bookmark') {
const bPath = decodeURIComponent(item.dataset.bpath);
const bPass = item.dataset.bpass === 'true';
const bProto = item.dataset.bproto;
navigateToBookmark(bPath, bPass, bProto);
popup.remove();
} else if (action === 'add-bookmark') {
const rawPath = decodeURIComponent(item.dataset.path);
addNewBookmark(rawPath);
popup.remove();
} else if (action === 'open-remote-modal') {
openRemoteModal(targetPane);
popup.remove();
} else if (action === 'open-fleet-modal') {
openFleetManagerModal();
popup.remove();
}
});
const favBtn = document.getElementById(`btn-favorites-${paneIndex}`);
const toolsBtn = document.getElementById(`pane-tools-btn-${paneIndex}`);
const anchorBtn = (favBtn && favBtn.offsetParent !== null) ? favBtn : toolsBtn;
if (anchorBtn) {
const rect = anchorBtn.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = `${rect.bottom + 4}px`;
popup.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
popup.style.zIndex = '999999';
} else {
popup.style.position = 'fixed';
popup.style.top = '60px';
popup.style.right = '10px';
popup.style.zIndex = '999999';
}
document.body.appendChild(popup);
if (window.lucide) lucide.createIcons();
setTimeout(() => {
const closeFavHandler = (ev) => {
if (!popup.contains(ev.target) && !anchorBtn?.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closeFavHandler);
document.removeEventListener('touchstart', closeFavHandler);
}
};
document.addEventListener('click', closeFavHandler);
document.addEventListener('touchstart', closeFavHandler);
}, 10);
}
function toggleGlobalDotfiles(show) {
App.showHiddenDefault = show;
localStorage.setItem('cd_show_hidden', show ? 'true' : 'false');
App.panes.forEach(pane => {
pane.showHidden = show;
});
refreshAllPanes();
queueSaveUserPreferencesToServer();
}
// ---------------- PARANOID DRY RUN ----------------
async function showParanoidConfirm(action, sources, destination, onProceed, sourcePaneIdx) {
const modalBody = document.getElementById('paranoid-modal-body');
if (modalBody) {
modalBody.innerHTML = '<div>Analyzing filesystem transaction safety & disk availability...</div>';
}
showModal('paranoid-modal');
const srcIdx = (typeof sourcePaneIdx === 'number' && App.panes[sourcePaneIdx]) ? sourcePaneIdx : App.activePaneIndex;
const endpoint = getPaneEndpoint(srcIdx);
const headers = getPaneAuthHeaders(srcIdx, { 'Content-Type': 'application/json' });
// Clean and normalize source paths (decode URI components for spaces and special characters)
const cleanSources = (sources || []).map(s => {
if (typeof s === 'string') {
try {
return decodeURIComponent(s);
} catch (_) {
return s;
}
}
return s;
});
const cleanDestination = (typeof destination === 'string') ? (() => {
try {
return decodeURIComponent(destination);
} catch (_) {
return destination;
}
})() : destination;
try {
const resp = await fetch(`${endpoint}/api/tools/paranoid/dry-run`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ action, sources: cleanSources, destination: cleanDestination })
});
if (resp.ok) {
const report = await resp.json();
if (modalBody) {
modalBody.innerHTML = `
<div style="font-size: 12px; line-height: 1.6;">
<p><b>Action:</b> <span style="text-transform: uppercase; color: var(--accent);">${escapeHtml(report.action || action)}</span></p>
<p><b>Items:</b> ${(report.sources || cleanSources).length} files/directories (${formatBytes(report.estimated_total_bytes || 0)})</p>
<p><b>Destination:</b> ${escapeHtml(report.destination_target || cleanDestination || 'N/A')}</p>
<p><b>Post-Transfer Verification:</b> <span style="color: var(--success);">SHA-256 Checksum Validation</span></p>
${report.warnings && report.warnings.length > 0 ? `
<div style="background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); padding: 8px; border-radius: 4px; margin-top: 8px;">
<b>Warnings:</b>
<ul style="padding-left: 16px; margin: 4px 0 0;">
${report.warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}
const proceedBtn = document.getElementById('btn-paranoid-proceed');
if (proceedBtn) {
proceedBtn.onclick = () => {
closeModal('paranoid-modal');
onProceed();
};
}
} else {
if (modalBody) {
modalBody.innerHTML = `
<div style="font-size: 12px; line-height: 1.6;">
<p><b>Action:</b> <span style="text-transform: uppercase; color: var(--accent);">${escapeHtml(action)}</span></p>
<p><b>Items:</b> ${cleanSources.length} files/directories</p>
<p><b>Destination:</b> ${escapeHtml(cleanDestination || 'N/A')}</p>
<p><b>Integrity:</b> <span style="color: var(--success);">SHA-256 Checksum Verification</span></p>
</div>
`;
}
const proceedBtn = document.getElementById('btn-paranoid-proceed');
if (proceedBtn) {
proceedBtn.onclick = () => {
closeModal('paranoid-modal');
onProceed();
};
}
}
} catch (err) {
if (modalBody) {
modalBody.innerHTML = `
<div style="font-size: 12px; line-height: 1.6;">
<p><b>Action:</b> <span style="text-transform: uppercase; color: var(--accent);">${escapeHtml(action)}</span></p>
<p><b>Items:</b> ${cleanSources.length} files/directories</p>
<p><b>Destination:</b> ${escapeHtml(cleanDestination || 'N/A')}</p>
<p><b>Integrity:</b> <span style="color: var(--success);">SHA-256 Checksum Verification</span></p>
</div>
`;
}
const proceedBtn = document.getElementById('btn-paranoid-proceed');
if (proceedBtn) {
proceedBtn.onclick = () => {
closeModal('paranoid-modal');
onProceed();
};
}
}
}
// ---------------- FILE TEMPLATES ENGINE ----------------
function getFileTemplates() {
try {
const raw = localStorage.getItem('cd_file_templates');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to load cd_file_templates from localStorage:', e);
}
return [...DEFAULT_FILE_TEMPLATES];
}
function saveFileTemplates(templates) {
try {
localStorage.setItem('cd_file_templates', JSON.stringify(templates));
queueSaveUserPreferencesToServer();
} catch (e) {
console.error('Failed to save cd_file_templates:', e);
}
}
function resetFileTemplatesToDefault() {
saveFileTemplates(DEFAULT_FILE_TEMPLATES);
renderFileTemplatesList();
showToast('Reset file templates to default suite', 'success');
}
function humanizeFilenameToTitle(filename) {
if (!filename) return 'Document';
const base = filename.replace(/\.[^/.]+$/, '');
return base
.replace(/[-_]+/g, ' ')
.trim()
.replace(/\b\w/g, c => c.toUpperCase());
}
function expandTemplateVariables(templateStr, filename, customTitle = null) {
if (!templateStr) return '';
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const timeStr = now.toTimeString().split(' ')[0];
const isoDate = now.toISOString();
const year = now.getFullYear().toString();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const user = App.currentUser || 'User';
const author = App.currentUser || 'Brum User';
const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : 'document';
const ext = filename && filename.includes('.') ? filename.split('.').pop() : '';
const title = (customTitle && customTitle.trim()) ? customTitle.trim() : humanizeFilenameToTitle(filename);
return templateStr
.replace(/\{\{TITLE\}\}/g, title)
.replace(/\{\{FILENAME\}\}/g, filename || '')
.replace(/\{\{NAME\}\}/g, baseName)
.replace(/\{\{EXT\}\}/g, ext)
.replace(/\{\{DATE\}\}/g, dateStr)
.replace(/\{\{TIME\}\}/g, timeStr)
.replace(/\{\{ISO_DATE\}\}/g, isoDate)
.replace(/\{\{DATETIME\}\}/g, `${dateStr} ${timeStr}`)
.replace(/\{\{YEAR\}\}/g, year)
.replace(/\{\{MONTH\}\}/g, month)
.replace(/\{\{DAY\}\}/g, day)
.replace(/\{\{USER\}\}/g, user)
.replace(/\{\{AUTHOR\}\}/g, author);
}
function generateTemplateSubmenuItems() {
const templates = getFileTemplates();
if (!templates || templates.length === 0) {
return '<div style="color: var(--text-muted); font-size: 11px; padding: 4px 10px;">No templates configured</div>';
}
return templates.map(t => `
<div class="context-item" onclick="triggerCreateFromTemplate('${escapeHtml(t.id)}', App.contextPaneIndex ?? App.activePaneIndex); hideContextMenu();">
<i data-lucide="${escapeHtml(t.icon || 'file-text')}" style="width: 13px; color: var(--accent);"></i>
<span>${escapeHtml(t.name)}</span>
</div>
`).join('');
}
let activeCreatingTemplate = null;
let activeTemplatePaneIndex = 0;
function triggerCreateFromTemplate(templateId, paneIndex = App.activePaneIndex) {
const templates = getFileTemplates();
const template = templates.find(t => t.id === templateId) || templates[0];
if (!template) return;
activeCreatingTemplate = template;
activeTemplatePaneIndex = paneIndex;
const pane = App.panes[paneIndex];
const targetDir = pane?.path || '/';
const initialFilename = expandTemplateVariables(template.defaultFilename || `new_file.${template.ext || 'txt'}`, '');
const initialTitle = humanizeFilenameToTitle(initialFilename);
const titleEl = document.getElementById('template-modal-title');
if (titleEl) titleEl.textContent = `New ${template.name}`;
const iconEl = document.getElementById('template-modal-icon');
if (iconEl) iconEl.setAttribute('data-lucide', template.icon || 'file-code-2');
const destEl = document.getElementById('template-modal-dest');
if (destEl) destEl.textContent = targetDir;
const fileInput = document.getElementById('template-filename-input');
if (fileInput) fileInput.value = initialFilename;
const docTitleInput = document.getElementById('template-title-input');
if (docTitleInput) docTitleInput.value = initialTitle;
updateTemplateLivePreview();
showModal('new-from-template-modal');
if (window.lucide) lucide.createIcons();
setTimeout(() => {
const input = document.getElementById('template-filename-input');
if (input) {
input.focus();
const dotIdx = input.value.lastIndexOf('.');
if (dotIdx > 0) {
input.setSelectionRange(0, dotIdx);
} else {
input.select();
}
}
}, 50);
}
function updateTemplateLivePreview() {
if (!activeCreatingTemplate) return;
const filename = document.getElementById('template-filename-input')?.value.trim() || '';
const titleInput = document.getElementById('template-title-input');
const title = titleInput ? titleInput.value : '';
const previewEl = document.getElementById('template-preview-code');
if (previewEl) {
previewEl.value = expandTemplateVariables(activeCreatingTemplate.content, filename, title);
}
}
async function executeCreateFromTemplate() {
if (!activeCreatingTemplate) return;
const filename = document.getElementById('template-filename-input')?.value.trim();
if (!filename) {
showToast('Please enter a valid filename', 'error');
return;
}
const content = document.getElementById('template-preview-code')?.value || '';
const pane = App.panes[activeTemplatePaneIndex] || App.panes[App.activePaneIndex];
const targetDir = (pane?.path || '/').replace(/\/$/, '');
const newFilePath = `${targetDir}/${filename}`;
const authTarget = resolveAuthUri(newFilePath);
const openInEditor = document.getElementById('template-open-editor-chk')?.checked;
try {
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: authTarget, content })
});
closeModal('new-from-template-modal');
if (resp.ok) {
showToast(`Created '${filename}' from template`, 'success');
refreshAllPanes();
const updatedPane = App.panes[activeTemplatePaneIndex];
if (updatedPane && updatedPane.entries) {
const foundIdx = updatedPane.entries.findIndex(e => e.name === filename);
if (foundIdx !== -1) {
updatedPane.cursorIndex = foundIdx;
renderPaneTable(activeTemplatePaneIndex);
}
}
if (openInEditor) {
openEditorWithFile(newFilePath);
}
} else {
showToast(`Failed to create file: ${sanitizeCredentials(await resp.text())}`, 'error');
}
} catch (e) {
showToast(`Error creating file from template: ${e}`, 'error');
}
}
function copyVariableTag(tag) {
if (navigator.clipboard) {
navigator.clipboard.writeText(tag).then(() => {
showToast(`Copied ${tag} to clipboard`, 'info');
}).catch(() => {
showToast(`Selected: ${tag}`, 'info');
});
}
}
function openCreateTemplateModal(prefill = null) {
const idEl = document.getElementById('edit-template-id');
if (idEl) idEl.value = prefill?.id || '';
const hdrEl = document.getElementById('edit-template-header-title');
if (hdrEl) hdrEl.textContent = prefill ? 'Edit File Template' : 'Create New File Template';
const nameEl = document.getElementById('edit-template-name');
if (nameEl) nameEl.value = prefill?.name || '';
const extEl = document.getElementById('edit-template-ext');
if (extEl) extEl.value = prefill?.ext || '';
const defEl = document.getElementById('edit-template-default-filename');
if (defEl) defEl.value = prefill?.defaultFilename || '';
const iconEl = document.getElementById('edit-template-icon');
if (iconEl) iconEl.value = prefill?.icon || 'file-code';
const contentEl = document.getElementById('edit-template-content');
if (contentEl) contentEl.value = prefill?.content || '';
showModal('template-edit-modal');
if (window.lucide) lucide.createIcons();
}
function insertVariableIntoEditTemplate(varTag) {
const textarea = document.getElementById('edit-template-content');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
textarea.value = text.substring(0, start) + varTag + text.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + varTag.length;
textarea.focus();
}
function saveTemplateFromModal() {
const id = document.getElementById('edit-template-id')?.value.trim() || `custom_${Date.now()}`;
const name = document.getElementById('edit-template-name')?.value.trim();
const ext = document.getElementById('edit-template-ext')?.value.trim().replace(/^\./, '');
const defaultFilename = document.getElementById('edit-template-default-filename')?.value.trim() || `new_file.${ext || 'txt'}`;
const icon = document.getElementById('edit-template-icon')?.value || 'file-code';
const content = document.getElementById('edit-template-content')?.value || '';
if (!name) {
showToast('Please enter a template name', 'error');
return;
}
const templates = getFileTemplates();
const existingIdx = templates.findIndex(t => t.id === id);
const newTemplate = {
id,
name,
ext,
defaultFilename,
icon,
content,
isCustom: true
};
if (existingIdx !== -1) {
templates[existingIdx] = { ...templates[existingIdx], ...newTemplate };
} else {
templates.push(newTemplate);
}
saveFileTemplates(templates);
closeModal('template-edit-modal');
renderFileTemplatesList();
showToast(`Saved template '${name}'`, 'success');
}
function deleteFileTemplate(templateId) {
let templates = getFileTemplates();
templates = templates.filter(t => t.id !== templateId);
saveFileTemplates(templates);
renderFileTemplatesList();
showToast('Template removed', 'info');
}
function duplicateFileTemplate(templateId) {
const templates = getFileTemplates();
const t = templates.find(item => item.id === templateId);
if (!t) return;
const clone = {
...t,
id: `custom_${Date.now()}`,
name: `${t.name} (Copy)`,
isCustom: true
};
templates.push(clone);
saveFileTemplates(templates);
renderFileTemplatesList();
showToast(`Duplicated '${t.name}'`, 'success');
}
async function saveContextItemAsTemplate() {
const item = App.contextItem;
if (!item) {
showToast('No file selected to save as template', 'error');
return;
}
try {
const authPath = resolveAuthUri(item.path);
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(authPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
let content = '';
if (resp.ok) {
content = await resp.text();
}
const ext = item.name.includes('.') ? item.name.split('.').pop() : 'txt';
const base = item.name.replace(/\.[^/.]+$/, '');
const defaultFilename = `new_${item.name}`;
openCreateTemplateModal({
id: `custom_${Date.now()}`,
name: `${humanizeFilenameToTitle(base)} Template`,
ext,
defaultFilename,
icon: 'file-code',
content
});
} catch (e) {
showToast(`Error reading file: ${e}`, 'error');
}
}
function editTemplateById(templateId) {
const templates = getFileTemplates();
const t = templates.find(item => item.id === templateId);
if (t) {
openCreateTemplateModal(t);
}
}
function renderFileTemplatesList() {
const container = document.getElementById('settings-templates-list');
if (!container) return;
const templates = getFileTemplates();
if (templates.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); font-size: 12px; padding: 20px; text-align: center;">No templates configured. Click "Reset Defaults" above.</div>';
return;
}
container.innerHTML = templates.map(t => {
const isBuiltin = DEFAULT_FILE_TEMPLATES.some(d => d.id === t.id && !t.isCustom);
const badgeClass = isBuiltin ? 'builtin' : 'custom';
const badgeText = isBuiltin ? 'Built-in' : 'Custom';
return `
<div class="template-card">
<div style="display: flex; align-items: center; gap: 10px; min-width: 0;">
<div style="width: 32px; height: 32px; border-radius: 6px; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.25); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<i data-lucide="${escapeHtml(t.icon || 'file-text')}" style="width: 16px; height: 16px; color: var(--accent);"></i>
</div>
<div style="min-width: 0;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 700; font-size: 12px; color: var(--text-main);">${escapeHtml(t.name)}</span>
<span class="template-badge ${badgeClass}">${badgeText}</span>
${t.ext ? `<span style="font-size: 10px; font-family: var(--font-mono); color: var(--text-dim);">.${escapeHtml(t.ext)}</span>` : ''}
</div>
<div style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
Default: ${escapeHtml(t.defaultFilename || 'untitled')}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
<button class="btn btn-xs" onclick="triggerCreateFromTemplate('${escapeHtml(t.id)}'); closeModal('settings-modal');" title="Test / Create File from this Template"><i data-lucide="play" style="width: 11px;"></i> Test</button>
<button class="btn btn-xs" onclick="editTemplateById('${escapeHtml(t.id)}')" title="Edit Template"><i data-lucide="edit-3" style="width: 11px;"></i> Edit</button>
<button class="btn btn-xs" onclick="duplicateFileTemplate('${escapeHtml(t.id)}')" title="Duplicate Template"><i data-lucide="copy" style="width: 11px;"></i></button>
${!isBuiltin ? `<button class="btn btn-xs btn-danger" onclick="deleteFileTemplate('${escapeHtml(t.id)}')" title="Delete Custom Template"><i data-lucide="trash-2" style="width: 11px;"></i></button>` : ''}
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: container });
}
// ---------------- CONTEXT MENU & UI ACTIONS ----------------
let activePropertiesEntry = null;
function adjustSubmenuPosition(itemEl) {
if (window.innerWidth <= 768) return;
const submenu = itemEl.querySelector(':scope > .context-submenu');
if (!submenu) return;
submenu.style.left = 'calc(100% - 2px)';
submenu.style.right = 'auto';
submenu.style.top = '-4px';
submenu.style.bottom = 'auto';
const subRect = submenu.getBoundingClientRect();
const pad = 10;
if (subRect.right > window.innerWidth - pad) {
submenu.style.left = 'auto';
submenu.style.right = 'calc(100% - 2px)';
}
if (subRect.bottom > window.innerHeight - 36) {
const overflow = subRect.bottom - (window.innerHeight - 36);
const newTop = -4 - overflow;
submenu.style.top = `${newTop}px`;
}
}
/**
* Match a file name or directory against ChewToy declared file_extensions patterns
*/
function matchesChewToyFileExtension(patterns, filename, isDir = false) {
if (!patterns || !Array.isArray(patterns) || patterns.length === 0) return false;
if (!filename && !isDir) return false;
const nameLower = (filename || '').toLowerCase();
return patterns.some(pattern => {
if (!pattern || typeof pattern !== 'string') return false;
let pat = pattern.trim().toLowerCase();
if (!pat) return false;
if (pat === '*' || pat === '*.*') return true;
if (pat === 'dir' || pat === 'directory' || pat === 'folder' || pat === 'dirs') return isDir;
if (isDir) return false;
if (pat.startsWith('*.')) pat = pat.slice(2);
else if (pat.startsWith('.')) pat = pat.slice(1);
if (pat.includes('.')) {
return nameLower.endsWith('.' + pat);
}
const ext = nameLower.includes('.') ? nameLower.split('.').pop() : '';
return ext === pat || nameLower === pat;
});
}
/**
* Render dynamic context menu actions for active installed ChewToys matching current item
*/
function renderChewToyContextMenuItems(contextItem) {
if (!contextItem) return '';
const activeChewToys = (window.installedChewToys || []).filter(p => p.is_enabled !== false && p.enabled !== false);
if (activeChewToys.length === 0) return '';
let html = '';
activeChewToys.forEach(p => {
const integrations = p.manifest?.integrations || p.integrations;
if (!integrations) return;
const fileExts = integrations.file_extensions;
if (!fileExts || !Array.isArray(fileExts) || fileExts.length === 0) return;
if (matchesChewToyFileExtension(fileExts, contextItem.name, contextItem.is_dir)) {
const label = integrations.context_menu_label || `Open in ${p.name || p.id}`;
const iconUrl = getChewtoyIconUrl(p);
const targetPath = escapeHtml(contextItem.path || '');
const pluginId = escapeHtml(p.id);
html += `
<div class="context-item" onclick="openDynamicChewToy('${pluginId}', { targetFile: '${targetPath}', selectedFiles: getSelectedOrCursorPaths() }); hideContextMenu();">
<img src="${escapeHtml(iconUrl)}" alt="" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;" onerror="this.src='assets/amber-frameless-apps.webp'">
${escapeHtml(label)}
</div>
`;
}
});
return html;
}
function showContextMenu(x, y) {
const menu = document.getElementById('context-menu');
if (!menu) return;
const visibleCount = getVisiblePaneCount();
const currentIdx = App.activePaneIndex;
let copyPaneItems = '';
let movePaneItems = '';
for (let i = 0; i < visibleCount; i++) {
const p = App.panes[i];
const isCurrent = (i === currentIdx);
const shortP = p.path.length > 20 ? '...' + p.path.slice(-17) : p.path;
const label = `Pane ${i + 1} (${shortP})${isCurrent ? ' • Current' : ''}`;
if (!isCurrent) {
copyPaneItems += `<div class="context-item" onclick="quickTransferToPane('copy', ${i})"><i data-lucide="layout" style="width:13px;"></i> ${escapeHtml(label)}</div>`;
movePaneItems += `<div class="context-item" onclick="quickTransferToPane('move', ${i})"><i data-lucide="layout" style="width:13px;"></i> ${escapeHtml(label)}</div>`;
}
}
const favCopyItems = App.quickDestinations.map(d => `
<div class="context-item" onclick="quickTransferToPath('copy', '${escapeHtml(d.path)}')"><i data-lucide="folder" style="width:13px;"></i> ${escapeHtml(d.name)}</div>
`).join('');
const favMoveItems = App.quickDestinations.map(d => `
<div class="context-item" onclick="quickTransferToPath('move', '${escapeHtml(d.path)}')"><i data-lucide="folder" style="width:13px;"></i> ${escapeHtml(d.name)}</div>
`).join('');
const targetPane = App.panes[App.contextPaneIndex ?? App.activePaneIndex];
const isContextSelected = App.contextItem && targetPane?.selected?.has(App.contextItem.path);
const selectedCount = (isContextSelected && targetPane?.selected?.size > 1) ? targetPane.selected.size : 1;
const isDir = App.contextItem ? !!App.contextItem.is_dir : false;
const filename = App.contextItem?.name || '';
const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : '';
const matchingRules = OpenWithRules.filter(r => r.exts.includes('*') || r.exts.map(e => e.toLowerCase()).includes(ext));
const clipInfo = App.clipboard ? `(${App.clipboard.paths.length} item${App.clipboard.paths.length > 1 ? 's' : ''})` : '';
const oppositeIdx = visibleCount > 1 ? (currentIdx + 1) % visibleCount : null;
let openWithItems = '';
if (matchingRules.length > 0) {
matchingRules.forEach(r => {
openWithItems += `<div class="context-item" onclick="executeOpenWith('${escapeHtml(App.contextItem?.path || '')}', '${escapeHtml(r.cmd)}'); hideContextMenu();">
<i data-lucide="${r.icon || 'external-link'}" style="width: 13px;"></i> ${escapeHtml(r.name)}
</div>`;
});
} else {
openWithItems += `<div class="context-item" onclick="executeOpenWith('${escapeHtml(App.contextItem?.path || '')}', null); hideContextMenu();">
<i data-lucide="external-link" style="width: 13px;"></i> System Default App
</div>`;
}
openWithItems += `<div class="context-sep"></div><div class="context-item" onclick="openSettings(); switchSettingsTab('tab-openwith'); hideContextMenu();">
<i data-lucide="settings" style="width: 13px; color: var(--accent);"></i> Configure Handlers...
</div>`;
let userCustomActions = '';
if (CustomShellActions.length > 0) {
CustomShellActions.forEach(a => {
userCustomActions += `<div class="context-item" onclick="executeCustomAction('${escapeHtml(a.cmd)}', '${escapeHtml(App.contextItem?.path || '')}'); hideContextMenu();">
<i data-lucide="${a.icon || 'terminal'}" style="width: 13px;"></i> ${escapeHtml(a.label)}
</div>`;
});
} else {
userCustomActions = '<div style="color: var(--text-muted); font-size: 11px; padding: 4px 8px;">No custom actions</div>';
}
userCustomActions += `<div class="context-sep"></div><div class="context-item" onclick="openSettings(); switchSettingsTab('tab-context'); hideContextMenu();">
<i data-lucide="settings" style="width: 13px; color: var(--accent);"></i> Manage Shell Actions...
</div>`;
const curTagInfo = App.contextItem ? fileTagsMap.get(App.contextItem.path) : null;
const curColor = curTagInfo?.color_label;
const curTags = curTagInfo?.tags || [];
const hasTags = curTags.length > 0;
const colorMap = {
red: '#ef4444',
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
purple: '#a855f7'
};
const activeColorHex = curColor && colorMap[curColor] ? colorMap[curColor] : null;
// Header icon & label
let headerIconHtml = '';
let headerLabel = '';
if (selectedCount > 1) {
headerIconHtml = '<i data-lucide="layers" style="width: 14px; color: var(--accent);"></i>';
headerLabel = `⚡ ${selectedCount} items selected`;
} else if (isDir) {
headerIconHtml = formatCustomIconToHtml('', 'sm', true, 'var(--accent)');
headerLabel = escapeHtml(App.contextItem?.name || 'Folder Actions');
} else {
headerIconHtml = renderFileIconHtml(App.contextItem?.name || '', false, App.contextItem?.is_archive, App.contextItem?.path || '', 'sm');
headerLabel = escapeHtml(App.contextItem?.name || 'File Actions');
}
// --- BUILD CONTEXT MENU BODY ---
let bodyHtml = '';
// Case 0: Inside Trash Bin
if (isTrashDirectory(targetPane?.path)) {
const paneIdx = App.contextPaneIndex ?? App.activePaneIndex;
bodyHtml = `
<div class="context-item" onclick="restoreSelectedTrashItems(${paneIdx}); hideContextMenu();">
<i data-lucide="rotate-ccw" style="width: 14px; color: var(--accent);"></i> Restore to Original Location
</div>
<div class="context-item" onclick="deleteSelectedTrashItems(${paneIdx}); hideContextMenu();">
<i data-lucide="x-circle" style="width: 14px; color: var(--danger);"></i> Delete Permanently (Shift+Del)
</div>
<div class="context-sep"></div>
<div class="context-item" onclick="promptEmptyTrash(${paneIdx}); hideContextMenu();">
<i data-lucide="trash" style="width: 14px; color: var(--danger);"></i> Empty ${escapeHtml(getTrashDisplayName(true))}
</div>
<div class="context-sep"></div>
<div class="context-item" onclick="triggerProperties(); hideContextMenu();">
<i data-lucide="info" style="width: 14px; color: var(--accent);"></i> Properties (Alt+Enter)
</div>
`;
}
// Case A: Multi-selection Context Menu
else if (selectedCount > 1) {
bodyHtml = `
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="copy" style="width: 14px;"></i> Copy to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${copyPaneItems ? `<div class="submenu-header">Active Panes</div>${copyPaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favCopyItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('copy'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
<div class="context-item" onclick="addCurrentPaneToQuickDest(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width:13px;"></i> + Bookmark Current Path</div>
</div>
</div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="move" style="width: 14px;"></i> Move to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${movePaneItems ? `<div class="submenu-header">Active Panes</div>${movePaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favMoveItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('move'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
</div>
</div>
<div class="context-item" onclick="triggerCopyClipboard(); hideContextMenu();"><i data-lucide="clipboard-copy" style="width: 14px;"></i> Copy (Ctrl+C)</div>
<div class="context-item" onclick="triggerCutClipboard(); hideContextMenu();"><i data-lucide="scissors" style="width: 14px;"></i> Cut (Ctrl+X)</div>
<div class="context-item ${App.clipboard ? '' : 'disabled'}" onclick="triggerPaste(App.activePaneIndex); hideContextMenu();" style="${App.clipboard ? '' : 'opacity: 0.5; pointer-events: none;'}"><i data-lucide="clipboard-paste" style="width: 14px;"></i> Paste (Ctrl+V)</div>
<div class="context-item" onclick="openBatchRenamer(); hideContextMenu();"><i data-lucide="file-signature" style="width: 14px; color: var(--accent);"></i> Batch Rename... (Ctrl+M)</div>
<div class="context-item" onclick="triggerDelete(); hideContextMenu();"><i data-lucide="trash-2" style="width: 14px; color: var(--danger);"></i> Delete (F8)</div>
<div class="context-sep"></div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="archive" style="width: 14px; color: var(--accent);"></i> Archive Selection</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerArchiveZip(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .zip</div>
<div class="context-item" onclick="triggerArchive7z(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .7z</div>
<div class="context-item" onclick="triggerArchiveTarGz(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .tar.gz</div>
<div class="context-item" onclick="triggerCompressModal(); hideContextMenu();"><i data-lucide="package" style="width: 13px;"></i> Add to Archive...</div>
<div class="context-sep"></div>
<div class="context-item" onclick="triggerChecksum(); hideContextMenu();"><i data-lucide="shield-check" style="width: 13px;"></i> Calculate Checksums</div>
</div>
</div>
<div class="context-sep"></div>
<div class="context-item" onclick="triggerShare(); hideContextMenu();"><i data-lucide="share-2" style="width: 14px; color: var(--accent);"></i> Share Selection...</div>
<div class="context-item" onclick="triggerProperties(); hideContextMenu();"><i data-lucide="info" style="width: 14px; color: var(--accent);"></i> Properties (Alt+Enter)</div>
`;
}
// Case B: Dedicated Folder Context Menu
else if (isDir) {
bodyHtml = `
<div class="context-item" onclick="loadPaneDirectory(${App.contextPaneIndex ?? App.activePaneIndex}, App.contextItem?.path); hideContextMenu();"><i data-lucide="folder-open" style="width: 14px; color: var(--accent);"></i> Open Folder</div>
<div class="context-item" onclick="createPaneTab(${App.contextPaneIndex ?? App.activePaneIndex}, App.contextItem?.path); hideContextMenu();"><i data-lucide="plus-square" style="width: 14px;"></i> Open in New Tab</div>
${oppositeIdx !== null ? `
<div class="context-item" onclick="loadPaneDirectory(${oppositeIdx}, App.contextItem?.path); showToast('Opened in Pane ${oppositeIdx + 1}', 'info'); hideContextMenu();"><i data-lucide="columns-2" style="width: 14px;"></i> Open in Opposite Pane (Pane ${oppositeIdx + 1})</div>
` : ''}
<div class="context-item" onclick="openTerminalInPath(App.contextItem?.path); hideContextMenu();"><img src="assets/term.webp" alt="Terminal" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Open in Terminal (\`)</div>
<div class="context-item" onclick="openSearchModal(App.contextItem?.path); hideContextMenu();"><img src="assets/search.webp" alt="Search" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Search in Folder (Ctrl+F)</div>
<div class="context-item" onclick="triggerShare(); hideContextMenu();"><i data-lucide="share-2" style="width: 14px; color: var(--accent);"></i> Share Folder / Guest Dropbox...</div>
<div class="context-item" onclick="openSyncModal(); hideContextMenu();"><img src="assets/sync.webp" alt="Backup" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Backup (Sync & Replication)...</div>
<div class="context-item" onclick="openDiskUsageModal(App.contextItem?.path); hideContextMenu();"><img src="assets/amber-piechart.webp" alt="Stats" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Stats (Disk Usage & Treemap)...</div>
<div class="context-item" onclick="addDirectoryToSoundDog(App.contextItem?.path, true); hideContextMenu();"><img src="assets/amber-media.webp" alt="Play Folder" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Play Folder in Audioplayer</div>
<div class="context-item" onclick="addDirectoryToSoundDog(App.contextItem?.path, false); hideContextMenu();"><i data-lucide="list-plus" style="width: 14px; color: var(--accent);"></i> Add Folder to Queue</div>
<div class="context-item" onclick="toggleBranchView(${App.contextPaneIndex ?? App.activePaneIndex}); hideContextMenu();"><i data-lucide="git-branch" style="width: 14px; color: var(--accent);"></i> Flat / Branch View (Ctrl+B)</div>
<div class="context-item" onclick="triggerDownloadContextItem(); hideContextMenu();"><i data-lucide="download" style="width: 14px; color: var(--accent);"></i> Download Folder (.zip)</div>
<div class="context-item" onclick="triggerProperties(); hideContextMenu();"><i data-lucide="info" style="width: 14px; color: var(--accent);"></i> Properties (Alt+Enter)</div>
<div class="context-sep"></div>
<!-- New Submenu -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="plus-circle" style="width: 14px; color: var(--accent);"></i> New</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerMkdir(); hideContextMenu();"><i data-lucide="folder-plus" style="width: 13px; color: var(--accent);"></i> New Folder... (F7)</div>
<div class="context-item" onclick="triggerNewFile(); hideContextMenu();"><i data-lucide="file-plus" style="width: 13px;"></i> Blank File...</div>
<div class="context-sep"></div>
<div class="submenu-header">Templates</div>
${generateTemplateSubmenuItems()}
<div class="context-sep"></div>
<div class="context-item" onclick="openCreateTemplateModal(); hideContextMenu();"><i data-lucide="file-code-2" style="width: 13px;"></i> + Create Template...</div>
<div class="context-item" onclick="openSettings(); switchSettingsTab('tab-templates'); hideContextMenu();"><img src="assets/amber-frameless-settings.webp" alt="Settings" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Manage Templates...</div>
</div>
</div>
<div class="context-sep"></div>
<!-- Transfer & Folder Actions -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="copy" style="width: 14px;"></i> Copy to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${copyPaneItems ? `<div class="submenu-header">Active Panes</div>${copyPaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favCopyItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('copy'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
<div class="context-item" onclick="addCurrentPaneToQuickDest(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width:13px;"></i> + Bookmark Current Path</div>
</div>
</div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="move" style="width: 14px;"></i> Move to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${movePaneItems ? `<div class="submenu-header">Active Panes</div>${movePaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favMoveItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('move'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
</div>
</div>
<div class="context-item" onclick="triggerCopyClipboard(); hideContextMenu();"><i data-lucide="clipboard-copy" style="width: 14px;"></i> Copy (Ctrl+C)</div>
<div class="context-item" onclick="triggerCutClipboard(); hideContextMenu();"><i data-lucide="scissors" style="width: 14px;"></i> Cut (Ctrl+X)</div>
<div class="context-item ${App.clipboard ? '' : 'disabled'}" onclick="triggerPaste(App.activePaneIndex); hideContextMenu();" style="${App.clipboard ? '' : 'opacity: 0.5; pointer-events: none;'}"><i data-lucide="clipboard-paste" style="width: 14px;"></i> Paste ${clipInfo} (Ctrl+V)</div>
<div class="context-item" onclick="triggerRename(); hideContextMenu();"><i data-lucide="edit-3" style="width: 14px;"></i> Rename (F2)</div>
<div class="context-item" onclick="openBatchRenamer(); hideContextMenu();"><i data-lucide="file-signature" style="width: 14px; color: var(--accent);"></i> Batch Rename... (Ctrl+M)</div>
<div class="context-item" onclick="triggerDelete(); hideContextMenu();"><i data-lucide="trash-2" style="width: 14px; color: var(--danger);"></i> Delete (F8)</div>
<div class="context-sep"></div>
<!-- Archive Submenu -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="archive" style="width: 14px; color: var(--accent);"></i> Archive Folder</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerArchiveZip(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .zip</div>
<div class="context-item" onclick="triggerArchive7z(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .7z</div>
<div class="context-item" onclick="triggerArchiveTarGz(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .tar.gz</div>
<div class="context-item" onclick="triggerCompressModal(); hideContextMenu();"><i data-lucide="package" style="width: 13px;"></i> Add to Archive...</div>
</div>
</div>
<div class="context-sep"></div>
<!-- Folder Tools -->
<div class="context-item" onclick="triggerDirPermissions(${App.contextPaneIndex ?? App.activePaneIndex}); hideContextMenu();"><i data-lucide="lock" style="width: 14px;"></i> Directory Permissions & Ownership</div>
<div class="context-item" onclick="runPredefinedAction('du -sh "{dir}"', 'Folder Disk Usage'); hideContextMenu();"><i data-lucide="hard-drive" style="width: 14px;"></i> Check Disk Usage (du -sh)</div>
<div class="context-item" onclick="triggerGitManager(); hideContextMenu();"><i data-lucide="git-branch" style="width: 14px;"></i> Git Manager & Diff</div>
<div class="context-item" onclick="openRemoteModal(${App.contextPaneIndex ?? App.activePaneIndex}); hideContextMenu();"><i data-lucide="network" style="width: 14px;"></i> Mount Remote Storage Here...</div>
<div class="context-item" onclick="addCurrentPaneToQuickDest(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width: 14px;"></i> Bookmark Current Path</div>
<div class="context-item" onclick="openSharesManager(); hideContextMenu();"><img src="assets/sharemgr.webp" alt="Sharing" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Sharing Center (Manage Shares)...</div>
`;
}
// Case C: Dedicated File Context Menu
else {
bodyHtml = `
${isStandaloneMode() ? `
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="external-link" style="width: 14px; color: var(--accent);"></i> Open with...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${openWithItems}
</div>
</div>
` : ''}
<div class="context-item" onclick="triggerView(); hideContextMenu();"><i data-lucide="eye" style="width: 14px;"></i> Quick View (F3)</div>
<div class="context-item" onclick="triggerEditor(); hideContextMenu();"><img src="assets/edit.webp" alt="Edit" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Edit (F4)</div>
<div class="context-item" onclick="openHexEditor(App.contextItem?.path); hideContextMenu();"><i data-lucide="binary" style="width: 14px; color: var(--accent);"></i> Open in Hex Editor...</div>
${App.contextItem && isCadOr3dExtension(App.contextItem.name) ? `
<div class="context-item" onclick="openCadStudio(App.contextItem?.path); hideContextMenu();"><i data-lucide="box" style="width: 14px; color: var(--accent);"></i> Open in 3D CAD Studio</div>
` : ''}
${App.contextItem && isAudioExtension(App.contextItem.name) ? `
<div class="context-item" onclick="openSoundDog(App.contextItem?.path); hideContextMenu();"><img src="assets/amber-media.webp" alt="Play" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Play in Audioplayer</div>
<div class="context-item" onclick="addTracksToSoundDogQueue([App.contextItem?.path]); hideContextMenu();"><i data-lucide="list-plus" style="width: 14px; color: var(--accent);"></i> Add to Audioplayer Queue</div>
` : ''}
${App.contextItem && App.contextItem.name.toLowerCase().endsWith('.pdf') ? `
<div class="context-item" onclick="openPdfToolModal(App.contextItem?.path); hideContextMenu();"><img src="assets/amber-pdftool.webp" alt="PDF Studio" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> PDF Studio (Merge & Split)</div>
` : ''}
${renderChewToyContextMenuItems(App.contextItem)}
<div class="context-item" onclick="triggerDownloadContextItem(); hideContextMenu();"><i data-lucide="download" style="width: 14px; color: var(--accent);"></i> Save / Download File</div>
<div class="context-item" onclick="triggerShare(); hideContextMenu();"><i data-lucide="share-2" style="width: 14px; color: var(--accent);"></i> Share File / Advanced Sharing...</div>
<div class="context-item" onclick="triggerProperties(); hideContextMenu();"><i data-lucide="info" style="width: 14px; color: var(--accent);"></i> Properties (Alt+Enter)</div>
<div class="context-sep"></div>
<!-- New Submenu -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="plus-circle" style="width: 14px; color: var(--accent);"></i> New</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerMkdir(); hideContextMenu();"><i data-lucide="folder-plus" style="width: 13px; color: var(--accent);"></i> New Folder... (F7)</div>
<div class="context-item" onclick="triggerNewFile(); hideContextMenu();"><i data-lucide="file-plus" style="width: 13px;"></i> Blank File...</div>
<div class="context-sep"></div>
<div class="submenu-header">Templates</div>
${generateTemplateSubmenuItems()}
<div class="context-sep"></div>
<div class="context-item" onclick="saveContextItemAsTemplate(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width: 13px; color: var(--accent);"></i> Save File as Template...</div>
<div class="context-item" onclick="openCreateTemplateModal(); hideContextMenu();"><i data-lucide="file-code-2" style="width: 13px;"></i> + Create Template...</div>
<div class="context-item" onclick="openSettings(); switchSettingsTab('tab-templates'); hideContextMenu();"><img src="assets/amber-frameless-settings.webp" alt="Settings" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Manage Templates...</div>
</div>
</div>
<div class="context-sep"></div>
<!-- Transfer & File Operations -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="copy" style="width: 14px;"></i> Copy to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${copyPaneItems ? `<div class="submenu-header">Active Panes</div>${copyPaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favCopyItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('copy'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
<div class="context-item" onclick="addCurrentPaneToQuickDest(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width:13px;"></i> + Bookmark Current Path</div>
</div>
</div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="move" style="width: 14px;"></i> Move to...</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${movePaneItems ? `<div class="submenu-header">Active Panes</div>${movePaneItems}<div class="context-sep"></div>` : ''}
<div class="submenu-header">Favorite Destinations</div>
${favMoveItems}
<div class="context-sep"></div>
<div class="context-item" onclick="openCustomDestModal('move'); hideContextMenu();"><i data-lucide="folder-symlink" style="width:13px;"></i> Custom Folder...</div>
</div>
</div>
<div class="context-item" onclick="triggerCopyClipboard(); hideContextMenu();"><i data-lucide="clipboard-copy" style="width: 14px;"></i> Copy (Ctrl+C)</div>
<div class="context-item" onclick="triggerCutClipboard(); hideContextMenu();"><i data-lucide="scissors" style="width: 14px;"></i> Cut (Ctrl+X)</div>
<div class="context-item ${App.clipboard ? '' : 'disabled'}" onclick="triggerPaste(App.activePaneIndex); hideContextMenu();" style="${App.clipboard ? '' : 'opacity: 0.5; pointer-events: none;'}"><i data-lucide="clipboard-paste" style="width: 14px;"></i> Paste (Ctrl+V)</div>
<div class="context-item" onclick="triggerRename(); hideContextMenu();"><i data-lucide="edit-3" style="width: 14px;"></i> Rename (F2)</div>
<div class="context-item" onclick="openBatchRenamer(); hideContextMenu();"><i data-lucide="file-signature" style="width: 14px; color: var(--accent);"></i> Batch Rename... (Ctrl+M)</div>
<div class="context-item" onclick="triggerDelete(); hideContextMenu();"><i data-lucide="trash-2" style="width: 14px; color: var(--danger);"></i> Delete (F8)</div>
<div class="context-sep"></div>
<!-- Archive Submenu -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="archive" style="width: 14px; color: var(--accent);"></i> Archive</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerArchiveZip(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .zip</div>
<div class="context-item" onclick="triggerArchive7z(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .7z</div>
<div class="context-item" onclick="triggerArchiveTarGz(); hideContextMenu();"><i data-lucide="archive" style="width: 13px;"></i> Add to .tar.gz</div>
<div class="context-item" onclick="triggerCompressModal(); hideContextMenu();"><i data-lucide="package" style="width: 13px;"></i> Add to Archive...</div>
<div class="context-item" onclick="triggerExtract(); hideContextMenu();"><i data-lucide="folder-archive" style="width: 13px;"></i> Extract Here</div>
<div class="context-sep"></div>
<div class="context-item" onclick="triggerChecksum(); hideContextMenu();"><i data-lucide="shield-check" style="width: 13px;"></i> Calculate Checksums</div>
</div>
</div>
<div class="context-sep"></div>
<!-- Tools Submenu -->
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><img src="assets/amber-frameless-apps.webp" alt="Tools" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Tools</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
${isStandaloneMode() ? `
<div class="context-item" onclick="openExternalTerminal(App.contextItem?.path); hideContextMenu();"><i data-lucide="terminal" style="width: 13px; color: var(--accent);"></i> Open in External Terminal</div>
<div class="context-sep"></div>
` : ''}
<div class="context-item" onclick="openSearchModal(); hideContextMenu();"><img src="assets/search.webp" alt="Search" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Advanced Search (Ctrl+F)</div>
<div class="context-item" onclick="triggerDiff(); hideContextMenu();"><img src="assets/diff.webp" alt="Compare" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Compare / Diff (F9)</div>
<div class="context-item" onclick="triggerBulkRename(); hideContextMenu();"><i data-lucide="tags" style="width: 13px;"></i> Advanced Rename (Shift+F6)</div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="terminal-square" style="width: 13px; color: var(--accent);"></i> Custom Script Actions</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="submenu-header">User Actions</div>
${userCustomActions}
<div class="context-sep"></div>
<div class="submenu-header">Shell Actions</div>
<div class="context-item" onclick="runPredefinedAction('chmod +x "{file}"', 'Make Executable (chmod +x)'); hideContextMenu();"><i data-lucide="shield" style="width:13px;"></i> Make Executable (chmod +x)</div>
<div class="context-item" onclick="runPredefinedAction('stat "{file}"', 'File Stat Info'); hideContextMenu();"><i data-lucide="info" style="width:13px;"></i> Inspect Stat (stat)</div>
<div class="context-item" onclick="runPredefinedAction('du -sh "{file}"', 'Disk Usage'); hideContextMenu();"><i data-lucide="hard-drive" style="width:13px;"></i> Check Disk Usage (du -sh)</div>
<div class="context-item" onclick="runPredefinedAction('git -C "{dir}" log -n 10 --oneline --graph', 'Git Log'); hideContextMenu();"><i data-lucide="git-branch" style="width:13px;"></i> Git Recent Log (git log)</div>
<div class="context-item" onclick="runPredefinedAction('md5sum "{file}"', 'MD5 Hash'); hideContextMenu();"><i data-lucide="hash" style="width:13px;"></i> Calculate MD5 Hash</div>
<div class="context-item" onclick="runPredefinedAction('wc -l "{file}"', 'Line Count'); hideContextMenu();"><i data-lucide="list-ordered" style="width:13px;"></i> Count Lines (wc -l)</div>
</div>
</div>
<div class="context-item" onclick="openPdfToolModal(App.contextItem ? App.contextItem.path : null); hideContextMenu();"><img src="assets/amber-pdftool.webp" alt="PDF Studio" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> PDF Studio (Merge & Split)</div>
<div class="context-item" onclick="triggerFileSplit(); hideContextMenu();"><i data-lucide="scissors" style="width: 13px;"></i> Split Large File...</div>
<div class="context-item" onclick="triggerFileCombine(); hideContextMenu();"><i data-lucide="merge" style="width: 13px;"></i> Combine Part Files (.001, .002)...</div>
<div class="context-item" onclick="openSyncModal(); hideContextMenu();"><img src="assets/sync.webp" alt="Backup" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Backup (Sync & Replication)...</div>
<div class="context-item" onclick="openDiskUsageModal(); hideContextMenu();"><img src="assets/amber-piechart.webp" alt="Stats" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Stats (Disk Usage & Treemap)</div>
<div class="context-item" onclick="triggerGitManager(); hideContextMenu();"><i data-lucide="git-branch" style="width: 13px;"></i> Git Manager & Diff</div>
<div class="context-item" onclick="openDuplicateFinder(); hideContextMenu();"><i data-lucide="copy-check" style="width: 13px; color: var(--accent);"></i> Duplicate Finder...</div>
<div class="context-item" onclick="openTagEditor(); hideContextMenu();"><i data-lucide="tag" style="width: 13px; color: var(--accent);"></i> Tag Editor & EXIF...</div>
<div class="context-item" onclick="openHexEditor(App.contextItem ? App.contextItem.path : null); hideContextMenu();"><i data-lucide="binary" style="width: 13px; color: var(--accent);"></i> Hex Editor...</div>
<div class="context-item" onclick="openCadStudio(App.contextItem ? App.contextItem.path : null); hideContextMenu();"><i data-lucide="box" style="width: 13px; color: var(--accent);"></i> 3D CAD Studio...</div>
<div class="context-item" onclick="openLogViewer(App.contextItem ? App.contextItem.path : null); hideContextMenu();"><i data-lucide="scroll-text" style="width: 13px; color: var(--accent);"></i> Log Viewer...</div>
<div class="context-item" onclick="openSharesManager(); hideContextMenu();"><img src="assets/sharemgr.webp" alt="Sharing" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Sharing Center (Manage Shares)...</div>
</div>
</div>
${App.contextItem && isVaultFile(App.contextItem.name) ? `
<div class="context-sep"></div>
<div class="context-item" onclick="handleVaultOpen(App.contextItem?.path); hideContextMenu();"><i data-lucide="key" style="width: 14px; color: var(--accent);"></i> Unlock / Open Vault...</div>
<div class="context-item" onclick="disconnectPaneRemote(App.activePaneIndex); hideContextMenu();"><i data-lucide="lock" style="width: 14px; color: var(--danger);"></i> Lock Vault</div>
` : ''}
`;
}
menu.innerHTML = `
<!-- Top Header: Filename/Folder on left, Small Color Dot & Tag icon on right -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.15);">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 175px;" title="${escapeHtml(App.contextItem?.name || '')}">
${headerIconHtml}
<span style="font-weight: 700; font-size: 11px; font-family: var(--font-mono); color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${headerLabel}
</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="ctx-btn-color" onclick="toggleContextColorPalette(event)" title="Set Color Label" style="width: 12px; height: 12px; min-width: 12px; min-height: 12px; max-width: 12px; max-height: 12px; aspect-ratio: 1 / 1; border-radius: 50%; border: 1.5px solid ${activeColorHex || 'var(--border)'}; background: ${activeColorHex || 'rgba(255,255,255,0.15)'}; display: inline-block; flex-shrink: 0; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; box-shadow: ${activeColorHex ? `0 0 5px ${activeColorHex}` : 'none'};" onmouseenter="this.style.transform='scale(1.25)'" onmouseleave="this.style.transform='scale(1)'"></span>
<i data-lucide="tag" id="ctx-icon-tag" onclick="triggerEditTagsModal(); hideContextMenu();" title="${hasTags ? `Custom Tags (${escapeHtml(curTags.map(t => '#' + t).join(', '))})` : 'Custom Tags (None assigned)'}" style="width: 13px; height: 13px; color: ${hasTags ? '#22c55e' : 'var(--text-muted)'}; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; opacity: ${hasTags ? '1' : '0.55'}; filter: ${hasTags ? 'drop-shadow(0 0 4px rgba(34, 197, 94, 0.75))' : 'none'}; transition: opacity 0.15s, transform 0.15s, filter 0.15s;" onmouseenter="this.style.opacity='1'; this.style.transform='scale(1.2)';" onmouseleave="this.style.opacity='${hasTags ? '1' : '0.55'}'; this.style.transform='scale(1)';"></i>
</div>
</div>
<!-- Toggleable Minimal Color Palette Bar -->
<div id="ctx-color-palette-bar" style="display: none; padding: 6px 10px; border-bottom: 1px solid var(--border); background: rgba(0,0,0,0.28); justify-content: space-between; align-items: center;" onclick="event.stopPropagation()">
<div style="display: flex; gap: 5px; align-items: center;">
<span class="color-dot-mini color-red" onclick="setContextFileColor('red')" title="Red (Urgent)"></span>
<span class="color-dot-mini color-orange" onclick="setContextFileColor('orange')" title="Orange (Pending)"></span>
<span class="color-dot-mini color-yellow" onclick="setContextFileColor('yellow')" title="Yellow (Review)"></span>
<span class="color-dot-mini color-green" onclick="setContextFileColor('green')" title="Green (Approved)"></span>
<span class="color-dot-mini color-blue" onclick="setContextFileColor('blue')" title="Blue (Important)"></span>
<span class="color-dot-mini color-purple" onclick="setContextFileColor('purple')" title="Purple (Personal)"></span>
<span class="color-dot-mini color-none" onclick="setContextFileColor('none')" title="Clear Label">✕</span>
</div>
<button class="btn btn-xs btn-subtle" onclick="openPropertiesColorTab()" title="More colors & tags in Properties dialog" style="font-size: 10px; padding: 2px 6px; margin-left: 8px; border: 1px solid var(--border); border-radius: 4px; background: rgba(255,255,255,0.05); color: var(--accent); cursor: pointer; white-space: nowrap;">
More...
</button>
</div>
${bodyHtml}
`;
if (window.lucide) lucide.createIcons();
positionContextMenu(menu, x, y);
}
async function triggerDownloadContextItem() {
hideContextMenu();
const item = App.contextItem;
if (!item) return;
if (item.path && item.path.startsWith('client://')) {
if (item.is_dir) {
showToast('Client local folder downloading as zip is not supported directly; use server folder or copy files', 'info');
return;
}
try {
const handle = await resolveClientFileHandle(item.path, false);
if (!handle) {
showToast('Client local file not found', 'error');
return;
}
const file = await handle.getFile();
const blobUrl = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = blobUrl;
a.download = item.name || file.name || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
showToast(`Initiating download for ${item.name}...`, 'success');
} catch (e) {
showToast('Download error: ' + sanitizeCredentials(String(e)), 'error');
}
return;
}
const url = `/api/fs/download?path=${encodeURIComponent(item.path)}`;
const a = document.createElement('a');
a.href = url;
a.download = item.name || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast(`Initiating download for ${item.name}...`, 'info');
}
function triggerProperties(targetEntry) {
const pane = App.panes[App.contextPaneIndex ?? App.activePaneIndex];
const entry = targetEntry || App.contextItem || (pane?.entries ? pane.entries[pane.cursorIndex] : null);
if (!entry) {
showToast('No item selected to inspect properties', 'info');
return;
}
activePropertiesEntry = entry;
activePermEntry = entry;
const title = document.getElementById('prop-header-title');
const filenameInput = document.getElementById('prop-input-filename');
const typeDesc = document.getElementById('prop-file-type-desc');
if (title) title.textContent = `${entry.name} - Properties`;
if (filenameInput) filenameInput.value = entry.name;
updatePropertiesModalHeaderIcon(entry);
const ext = entry.name.includes('.') ? entry.name.split('.').pop().toUpperCase() : '';
if (typeDesc) {
typeDesc.textContent = entry.is_dir ? 'File Folder' : (ext ? `${ext} File` : 'System File');
}
const parts = entry.path.split('/').filter(Boolean);
parts.pop();
const parentLoc = parts.length === 0 ? '/' : '/' + parts.join('/');
document.getElementById('prop-val-location').textContent = parentLoc;
document.getElementById('prop-val-size').textContent = entry.is_dir ? '<DIR>' : `${formatBytes(entry.size)} (${(entry.size || 0).toLocaleString()} bytes)`;
document.getElementById('prop-val-created').textContent = entry.created ? formatDate(entry.created) : (entry.modified ? formatDate(entry.modified) : '-');
document.getElementById('prop-val-modified').textContent = formatDate(entry.modified);
document.getElementById('prop-val-mode').textContent = `${entry.permissions || '-'} (${entry.mode_octal || '-'})`;
document.getElementById('prop-val-contains').textContent = entry.is_dir ? 'Scanning contents...' : '1 File';
if (entry.is_dir) {
fetch(`/api/fs/list?path=${encodeURIComponent(entry.path)}`, { headers: { 'Authorization': `Bearer ${App.token}` } })
.then(res => res.json())
.then(data => {
if (data.entries) {
const files = data.entries.filter(e => !e.is_dir).length;
const dirs = data.entries.filter(e => e.is_dir).length;
const el = document.getElementById('prop-val-contains');
if (el) el.textContent = `${files} Files, ${dirs} Folders`;
}
}).catch(() => {});
}
const mediaSec = document.getElementById('prop-media-section');
const mediaGrid = document.getElementById('prop-media-grid');
if (mediaSec && mediaGrid) {
if (!entry.is_dir && (isImageExtension(entry.name) || isAudioExtension(entry.name) || isVideoExtension(entry.name))) {
mediaSec.style.display = 'block';
mediaGrid.innerHTML = '<div style="color:var(--text-muted); grid-column: span 2;">Reading media tags & EXIF...</div>';
fetch(`/api/fs/inspect-media?path=${encodeURIComponent(entry.path)}`, { headers: { 'Authorization': `Bearer ${App.token}` } })
.then(res => res.json())
.then(data => {
if (data.meta) {
let html = '';
if (data.meta.width && data.meta.height) {
html += `<div><span style="color:var(--text-muted);">Dimensions:</span> <strong>${data.meta.width} × ${data.meta.height}</strong></div>`;
}
if (data.meta.duration_sec) {
html += `<div><span style="color:var(--text-muted);">Duration:</span> <strong>${Math.round(data.meta.duration_sec)}s</strong></div>`;
}
if (data.meta.camera_make || data.meta.camera_model) {
html += `<div style="grid-column: span 2;"><span style="color:var(--text-muted);">Camera:</span> <strong>${data.meta.camera_make || ''} ${data.meta.camera_model || ''}</strong></div>`;
}
if (data.meta.codec) {
html += `<div><span style="color:var(--text-muted);">Codec:</span> <strong>${data.meta.codec}</strong></div>`;
}
if (data.meta.bitrate_kbps) {
html += `<div><span style="color:var(--text-muted);">Bitrate:</span> <strong>${data.meta.bitrate_kbps} kbps</strong></div>`;
}
mediaGrid.innerHTML = html || '<div style="color:var(--text-muted); grid-column: span 2;">No extended EXIF tags found</div>';
} else {
mediaGrid.innerHTML = '<div style="color:var(--text-muted); grid-column: span 2;">No media metadata available</div>';
}
}).catch(() => {
mediaGrid.innerHTML = '<div style="color:var(--text-muted); grid-column: span 2;">Could not read media tags</div>';
});
} else {
mediaSec.style.display = 'none';
}
}
setPropPermCheckboxesFromMode(entry.mode || (entry.mode_octal ? parseInt(entry.mode_octal, 8) : 0o755));
populatePropOwnerGroupDropdowns(entry.owner, entry.group);
const recWrapper = document.getElementById('prop-perm-recursive-wrapper');
if (recWrapper) recWrapper.style.display = entry.is_dir ? 'block' : 'none';
document.getElementById('prop-hash-sha256').value = '-';
document.getElementById('prop-hash-md5').value = '-';
document.getElementById('prop-hash-sha1').value = '-';
renderPropTagsTab(entry.path);
switchPropertiesTab('prop-tab-general');
showModal('properties-modal');
}
function switchPropertiesTab(tabId) {
const modal = document.getElementById('properties-modal');
if (!modal) return;
modal.querySelectorAll('.settings-tab-btn').forEach(btn => btn.classList.remove('active'));
modal.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
const btn = document.getElementById(`btn-${tabId}`);
if (btn) {
btn.classList.add('active');
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
const content = document.getElementById(tabId);
if (content) content.classList.add('active');
}
function setPropPermCheckboxesFromMode(mode) {
document.getElementById('prop-perm-u-r').checked = (mode & 0o400) !== 0;
document.getElementById('prop-perm-u-w').checked = (mode & 0o200) !== 0;
document.getElementById('prop-perm-u-x').checked = (mode & 0o100) !== 0;
document.getElementById('prop-perm-g-r').checked = (mode & 0o040) !== 0;
document.getElementById('prop-perm-g-w').checked = (mode & 0o020) !== 0;
document.getElementById('prop-perm-g-x').checked = (mode & 0o010) !== 0;
document.getElementById('prop-perm-o-r').checked = (mode & 0o004) !== 0;
document.getElementById('prop-perm-o-w').checked = (mode & 0o002) !== 0;
document.getElementById('prop-perm-o-x').checked = (mode & 0o001) !== 0;
document.getElementById('prop-perm-octal').textContent = '0' + (mode & 0o777).toString(8);
}
function updatePropPermFromCheckboxes() {
let mode = 0;
if (document.getElementById('prop-perm-u-r').checked) mode |= 0o400;
if (document.getElementById('prop-perm-u-w').checked) mode |= 0o200;
if (document.getElementById('prop-perm-u-x').checked) mode |= 0o100;
if (document.getElementById('prop-perm-g-r').checked) mode |= 0o040;
if (document.getElementById('prop-perm-g-w').checked) mode |= 0o020;
if (document.getElementById('prop-perm-g-x').checked) mode |= 0o010;
if (document.getElementById('prop-perm-o-r').checked) mode |= 0o004;
if (document.getElementById('prop-perm-o-w').checked) mode |= 0o002;
if (document.getElementById('prop-perm-o-x').checked) mode |= 0o001;
document.getElementById('prop-perm-octal').textContent = '0' + (mode & 0o777).toString(8);
}
function populatePropOwnerGroupDropdowns(currentOwner, currentGroup) {
const uSel = document.getElementById('prop-perm-owner');
const gSel = document.getElementById('prop-perm-group');
if (!uSel || !gSel) return;
uSel.innerHTML = '';
gSel.innerHTML = '';
(App.systemUsers || ['root']).forEach(u => {
const opt = document.createElement('option');
opt.value = u;
opt.textContent = u;
if (u === currentOwner) opt.selected = true;
uSel.appendChild(opt);
});
(App.systemGroups || ['root']).forEach(g => {
const opt = document.createElement('option');
opt.value = g;
opt.textContent = g;
if (g === currentGroup) opt.selected = true;
gSel.appendChild(opt);
});
}
async function savePropertiesSecurity() {
if (!activePropertiesEntry) return;
const modeStr = document.getElementById('prop-perm-octal').textContent;
const modeVal = parseInt(modeStr, 8);
const recursive = document.getElementById('prop-perm-recursive')?.checked || false;
const owner = document.getElementById('prop-perm-owner')?.value;
const group = document.getElementById('prop-perm-group')?.value;
const paths = [activePropertiesEntry.path];
try {
showToast('Applying permissions...', 'info');
await fetch('/api/fs/chmod', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ paths, mode: modeVal, recursive })
});
if (owner && group) {
await fetch('/api/fs/chown', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ paths, owner, group, recursive })
});
}
showToast('Security permissions updated', 'success');
refreshAllPanes();
} catch (err) {
showToast('Failed to apply permissions', 'error');
}
}
async function calculatePropertiesChecksums() {
if (!activePropertiesEntry || activePropertiesEntry.is_dir) {
showToast('Checksums only apply to individual files', 'info');
return;
}
const shaEl = document.getElementById('prop-hash-sha256');
const md5El = document.getElementById('prop-hash-md5');
const sha1El = document.getElementById('prop-hash-sha1');
if (shaEl) shaEl.value = 'Calculating SHA-256...';
if (md5El) md5El.value = 'Calculating MD5...';
if (sha1El) sha1El.value = 'Calculating SHA-1...';
try {
const res = await fetch(`/api/fs/checksum?path=${encodeURIComponent(activePropertiesEntry.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
const data = await res.json();
if (data.sha256) shaEl.value = data.sha256;
if (data.md5) md5El.value = data.md5;
if (data.sha1) sha1El.value = data.sha1 || '-';
showToast('Hashes calculated', 'success');
} catch (err) {
if (shaEl) shaEl.value = 'Calculation failed';
if (md5El) md5El.value = 'Calculation failed';
if (sha1El) sha1El.value = 'Calculation failed';
}
}
function copyPropHash(elementId) {
const el = document.getElementById(elementId);
if (!el || !el.value || el.value === '-' || el.value.includes('...')) return;
navigator.clipboard.writeText(el.value);
showToast('Checksum copied to clipboard', 'info');
}
function updatePropertiesModalHeaderIcon(entry) {
if (!entry) return;
const bigIconContainer = document.getElementById('prop-big-icon-container');
if (bigIconContainer) {
bigIconContainer.innerHTML = renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'lg');
}
const headerIcon = document.getElementById('prop-header-icon');
if (headerIcon) {
headerIcon.outerHTML = `<span id="prop-header-icon" style="display:inline-flex;align-items:center;margin-right:4px;">${renderFileIconHtml(entry.name, entry.is_dir, entry.is_archive, entry.path, 'sm')}</span>`;
}
if (window.lucide) lucide.createIcons();
}
function renderPropTagsTab(path) {
const container = document.getElementById('prop-tags-container');
const iconBadge = document.getElementById('prop-current-icon-badge');
const inputIcon = document.getElementById('prop-input-custom-icon');
const presetContainer = document.getElementById('prop-preset-buttons-container');
const tagInfo = fileTagsMap.get(path);
const curCustomIcon = tagInfo?.custom_icon || '';
if (iconBadge) {
if (curCustomIcon) {
iconBadge.innerHTML = `<span>Active:</span> ${formatCustomIconToHtml(curCustomIcon, 'sm')} <span style="font-family: var(--font-mono); font-size: 11px;">(${escapeHtml(curCustomIcon)})</span>`;
} else {
iconBadge.innerHTML = `<span style="color: var(--text-muted); font-size: 11px; font-weight: normal;">Default Icon</span>`;
}
}
if (inputIcon) {
inputIcon.value = curCustomIcon;
}
if (presetContainer) {
presetContainer.innerHTML = '';
COMMON_PRESET_ICONS.forEach(icon => {
const chip = document.createElement('span');
chip.className = `icon-btn-chip ${curCustomIcon === icon ? 'active' : ''}`;
chip.innerHTML = formatCustomIconToHtml(icon, 'sm');
chip.title = `Apply ${icon}`;
chip.onclick = () => setPropCustomIcon(icon);
presetContainer.appendChild(chip);
});
}
if (container) {
container.innerHTML = '';
const tags = tagInfo?.tags || [];
if (tags.length === 0) {
container.innerHTML = '<span style="color: var(--text-muted); font-size: 11px;">No custom tags assigned yet.</span>';
} else {
tags.forEach(tag => {
const pill = document.createElement('span');
pill.className = 'file-tag-badge';
pill.style.cssText = 'font-size: 12px; padding: 3px 8px; display: inline-flex; align-items: center; gap: 6px;';
pill.innerHTML = `<span>#${escapeHtml(tag)}</span><i data-lucide="x" style="width: 12px; height: 12px; cursor: pointer;" onclick="removePropCustomTag('${escapeHtml(tag)}')"></i>`;
container.appendChild(pill);
});
}
}
if (window.lucide) lucide.createIcons();
}
function applyPropCustomIconInput() {
if (!activePropertiesEntry) return;
const input = document.getElementById('prop-input-custom-icon');
const val = input ? input.value.trim() : '';
if (!val) {
clearPropCustomIcon();
return;
}
setPropCustomIcon(val);
}
function setPropCustomIcon(iconVal) {
if (!activePropertiesEntry) return;
setContextCustomIcon(iconVal, [activePropertiesEntry.path]);
}
function clearPropCustomIcon() {
if (!activePropertiesEntry) return;
setContextCustomIcon('', [activePropertiesEntry.path]);
}
function setPropFileColor(color) {
if (!activePropertiesEntry) return;
setContextFileColor(color);
}
function addPropCustomTag() {
if (!activePropertiesEntry) return;
const input = document.getElementById('prop-input-new-tag');
if (!input) return;
const val = input.value.trim().replace(/^#/, '');
if (!val) return;
const path = activePropertiesEntry.path;
const tagInfo = fileTagsMap.get(path) || { color_label: 'none', tags: [] };
if (!tagInfo.tags.includes(val)) {
tagInfo.tags.push(val);
fileTagsMap.set(path, tagInfo);
saveFileTagsToStorage();
renderPropTagsTab(path);
refreshAllPanes();
input.value = '';
showToast(`Added tag #${val}`, 'success');
}
}
function removePropCustomTag(tag) {
if (!activePropertiesEntry) return;
const path = activePropertiesEntry.path;
const tagInfo = fileTagsMap.get(path);
if (tagInfo && tagInfo.tags) {
tagInfo.tags = tagInfo.tags.filter(t => t !== tag);
fileTagsMap.set(path, tagInfo);
saveFileTagsToStorage();
renderPropTagsTab(path);
refreshAllPanes();
showToast(`Removed tag #${tag}`, 'info');
}
}
function triggerArchive7z() {
triggerCompressModal();
}
function positionContextMenu(menu, x, y) {
const backdrop = document.getElementById('context-menu-backdrop');
const actBtn = document.getElementById('mob-btn-actions');
if (window.innerWidth <= 768) {
// Mobile Bottom Sheet positioning
if (backdrop) backdrop.classList.add('active');
if (actBtn) actBtn.classList.add('active');
menu.classList.remove('submenu-flip-left');
menu.style.position = 'fixed';
menu.style.left = '10px';
menu.style.right = '10px';
menu.style.width = 'calc(100vw - 20px)';
menu.style.maxHeight = '70vh';
menu.style.overflowY = 'auto';
menu.style.overflowX = 'hidden';
menu.style.bottom = '56px';
menu.style.top = 'auto';
menu.style.borderRadius = '12px';
menu.style.display = 'block';
menu.style.visibility = 'visible';
return;
}
if (backdrop) backdrop.classList.remove('active');
// Desktop positioning with collision detection and automatic upward flip
menu.style.display = 'block';
menu.style.visibility = 'hidden';
menu.style.position = 'fixed';
menu.style.right = 'auto';
menu.style.bottom = 'auto';
menu.style.width = 'auto';
menu.style.borderRadius = '6px';
// Measure rendered menu dimensions
const menuWidth = menu.offsetWidth || 250;
const menuHeight = menu.offsetHeight || 500;
const pad = 10;
const bottomPad = 36; // Extra breathing room for function key bar / taskbar
// Horizontal placement
let left = x;
if (x + menuWidth > window.innerWidth - pad) {
left = Math.max(pad, window.innerWidth - menuWidth - pad);
menu.classList.add('submenu-flip-left');
} else if (x > window.innerWidth - 480) {
menu.classList.add('submenu-flip-left');
} else {
menu.classList.remove('submenu-flip-left');
}
left = Math.max(pad, left);
// Vertical placement: flip upwards if overflowing bottom!
let top = y;
const availableBelow = window.innerHeight - bottomPad - y;
if (menuHeight > availableBelow) {
// If opening downwards overflows, check if opening upwards fits better
const candidateUp = y - menuHeight;
if (candidateUp >= pad) {
top = candidateUp;
} else {
// If neither fits completely, clamp to top pad or fit within viewport
top = Math.max(pad, window.innerHeight - menuHeight - bottomPad);
}
}
menu.style.maxHeight = 'none';
menu.style.overflow = 'visible';
menu.style.overflowY = 'visible';
menu.style.overflowX = 'visible';
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.visibility = 'visible';
// Attach dynamic collision adjuster to all submenus
menu.querySelectorAll('.context-item.has-submenu').forEach(item => {
item.onmouseenter = () => adjustSubmenuPosition(item);
item.onmouseleave = () => {
if (window.innerWidth > 768) {
const sub = item.querySelector(':scope > .context-submenu');
if (sub) sub.style.display = '';
}
};
});
}
function toggleContextSubmenu(e, itemEl) {
if (window.innerWidth <= 768) {
e.stopPropagation();
const isOpen = itemEl.classList.contains('expanded');
const menu = itemEl.closest('.context-menu');
if (menu) {
menu.querySelectorAll('.context-item.has-submenu.expanded').forEach(el => {
if (el !== itemEl) el.classList.remove('expanded');
});
}
itemEl.classList.toggle('expanded', !isOpen);
} else {
e.stopPropagation();
adjustSubmenuPosition(itemEl);
}
}
function hideContextMenu() {
const menu = document.getElementById('context-menu');
if (menu) menu.style.display = 'none';
const backdrop = document.getElementById('context-menu-backdrop');
if (backdrop) backdrop.classList.remove('active');
const actBtn = document.getElementById('mob-btn-actions');
if (actBtn) actBtn.classList.remove('active');
}
function showTouchActionsMenu(e) {
if (e) {
e.stopPropagation();
e.preventDefault();
}
const menu = document.getElementById('context-menu');
// ON/OFF toggle: if context menu is currently open, toggle it off
if (menu && menu.style.display === 'block') {
hideContextMenu();
return;
}
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
if (pane.selected.size === 0) {
showEmptySpaceContextMenu(window.innerWidth / 2, window.innerHeight - 100, App.activePaneIndex);
return;
}
const firstPath = Array.from(pane.selected)[0];
const entry = pane.entries.find(it => it.path === firstPath) || { path: firstPath, name: firstPath.split('/').pop() };
App.contextItem = entry;
App.contextPaneIndex = App.activePaneIndex;
showContextMenu(window.innerWidth / 2, window.innerHeight - 100);
}
function showEmptySpaceContextMenu(x, y, paneIndex) {
const menu = document.getElementById('context-menu');
if (!menu) return;
setActivePane(paneIndex);
App.contextItem = null;
App.contextPaneIndex = paneIndex;
const pane = App.panes[paneIndex];
const clipInfo = App.clipboard ? `(${App.clipboard.paths.length} item${App.clipboard.paths.length > 1 ? 's' : ''})` : '';
menu.innerHTML = `
<div style="padding: 6px 12px; font-size: 11px; font-weight: 700; color: var(--accent); border-bottom: 1px solid var(--border); font-family: var(--font-mono); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; display: flex; align-items: center; gap: 6px;">
${formatCustomIconToHtml('', 'sm', true, 'var(--accent)')}
<span>${escapeHtml(pane.path.split('/').pop() || pane.path || '/')}</span>
</div>
<div class="context-item" onclick="openSpotlightModal(); hideContextMenu();"><i data-lucide="sparkles" style="width: 14px; color: var(--accent);"></i> Spotlight Quick-Switcher (Ctrl+K)...</div>
<div class="context-item" onclick="toggleBranchView(${paneIndex}); hideContextMenu();"><i data-lucide="git-branch" style="width: 14px; color: var(--accent);"></i> Flat / Branch View (Ctrl+B)</div>
<div class="context-item" onclick="triggerDeviceUpload(${paneIndex}); hideContextMenu();"><i data-lucide="upload" style="width: 14px; color: #38bdf8;"></i> Upload Files from Device...</div>
<div class="context-item" onclick="triggerDeviceFolderUpload(${paneIndex}); hideContextMenu();"><i data-lucide="folder-up" style="width: 14px; color: #38bdf8;"></i> Upload Folder from Device...</div>
<div class="context-item" onclick="triggerDownloadCurrentDirectory(${paneIndex}); hideContextMenu();"><i data-lucide="download" style="width: 14px;"></i> Download Directory (.zip)</div>
<div class="context-item" onclick="triggerShareDirectory(${paneIndex}); hideContextMenu();"><i data-lucide="share-2" style="width: 14px; color: var(--accent);"></i> Share Directory / Guest Dropbox...</div>
<div class="context-item" onclick="openSharesManager(); hideContextMenu();"><img src="assets/sharemgr.webp" alt="Sharing" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Sharing Center (Manage Shares)...</div>
<div class="context-sep"></div>
<div class="context-item has-submenu" onmouseenter="adjustSubmenuPosition(this)" onclick="toggleContextSubmenu(event, this)">
<div style="display:flex; align-items:center; gap:8px;"><i data-lucide="plus-circle" style="width: 14px; color: var(--accent);"></i> New</div>
<i data-lucide="chevron-right" class="submenu-chevron" style="width: 12px;"></i>
<div class="context-submenu">
<div class="context-item" onclick="triggerMkdir(); hideContextMenu();"><i data-lucide="folder-plus" style="width: 13px; color: var(--accent);"></i> New Folder... (F7)</div>
<div class="context-item" onclick="triggerNewFile(); hideContextMenu();"><i data-lucide="file-plus" style="width: 13px;"></i> Blank File...</div>
<div class="context-sep"></div>
<div class="submenu-header">Templates</div>
${generateTemplateSubmenuItems()}
<div class="context-sep"></div>
<div class="context-item" onclick="openCreateTemplateModal(); hideContextMenu();"><i data-lucide="file-code-2" style="width: 13px;"></i> + Create Template...</div>
<div class="context-item" onclick="openSettings(); switchSettingsTab('tab-templates'); hideContextMenu();"><img src="assets/amber-frameless-settings.webp" alt="Settings" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Manage Templates...</div>
</div>
</div>
<div class="context-item" onclick="openCreateVaultModal(); hideContextMenu();"><i data-lucide="shield-check" style="width: 14px; color: var(--accent);"></i> Create Encrypted Vault (.cdvault)...</div>
<div class="context-item ${App.clipboard ? '' : 'disabled'}" onclick="triggerPaste(${paneIndex}); hideContextMenu();" style="${App.clipboard ? '' : 'opacity: 0.5; pointer-events: none;'}">
<i data-lucide="clipboard-paste" style="width: 14px;"></i> Paste ${clipInfo} (Ctrl+V)
</div>
<div class="context-item" onclick="refreshAllPanes(); hideContextMenu();"><i data-lucide="rotate-cw" style="width: 14px;"></i> Refresh Directory</div>
<div class="context-sep"></div>
<div class="context-item" onclick="openTerminalInPath('${escapeHtml(pane.path)}'); hideContextMenu();"><img src="assets/term.webp" alt="Terminal" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Open in Terminal (\`)</div>
<div class="context-item" onclick="openSearchModal(); hideContextMenu();"><img src="assets/search.webp" alt="Search" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Search in Directory (Ctrl+F)</div>
<div class="context-item" onclick="openSyncModal(); hideContextMenu();"><img src="assets/sync.webp" alt="Backup" style="width: 13px; height: 13px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Backup (Sync & Replication)...</div>
<div class="context-item" onclick="openDiskUsageModal('${escapeHtml(pane.path)}'); hideContextMenu();"><img src="assets/amber-piechart.webp" alt="Stats" style="width: 14px; height: 14px; object-fit: contain; vertical-align: middle; margin-right: 4px;"> Stats (Disk Usage & Treemap)...</div>
<div class="context-item" onclick="openRemoteModal(${paneIndex}); hideContextMenu();"><i data-lucide="network" style="width: 14px;"></i> Mount Remote Storage Here...</div>
${pane.path.includes('://') ? `<div class="context-item" onclick="disconnectPaneRemote(${paneIndex}); hideContextMenu();" style="color: var(--danger, #ef4444);"><i data-lucide="log-out" style="width: 14px; color: var(--danger, #ef4444);"></i> Disconnect Remote Storage</div>` : ''}
<div class="context-item" onclick="addCurrentPaneToQuickDest(); hideContextMenu();"><i data-lucide="bookmark-plus" style="width: 14px;"></i> Bookmark Current Path</div>
<div class="context-sep"></div>
<div class="context-item" onclick="triggerDirPermissions(${paneIndex}); hideContextMenu();"><i data-lucide="lock" style="width: 14px;"></i> Directory Permissions & Ownership</div>
<div class="context-item" onclick="runPredefinedAction('du -sh "{dir}"', 'Directory Disk Usage'); hideContextMenu();"><i data-lucide="hard-drive" style="width: 14px;"></i> Check Disk Usage (du -sh)</div>
`;
if (window.lucide) lucide.createIcons();
positionContextMenu(menu, x, y);
}
function triggerCopyClipboard() {
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
App.clipboard = { action: 'copy', paths };
showToast(`Copied ${paths.length} item(s) to clipboard`, 'info');
}
function triggerCutClipboard() {
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
App.clipboard = { action: 'cut', paths };
showToast(`Cut ${paths.length} item(s) to clipboard`, 'info');
}
async function triggerPaste(targetPaneIdx = App.activePaneIndex) {
if (!App.clipboard || !App.clipboard.paths || App.clipboard.paths.length === 0) {
showToast('Clipboard is empty. Copy or Cut items first (Ctrl+C / Ctrl+X).', 'warning');
return;
}
const targetPane = App.panes[targetPaneIdx];
const action = App.clipboard.action;
const paths = App.clipboard.paths;
await executeTransfer(action, paths, targetPane.path, targetPaneIdx);
if (action === 'cut') {
App.clipboard = null;
}
}
function triggerGitManager() {
openGitManager(App.activePaneIndex);
}
function triggerFileSplit() {
const item = getActiveOrFirstSelectedItem();
if (!item || item.is_dir) {
showToast('Please select a file to split', 'warning');
return;
}
openFileSplitterModal(item.path, item.size);
}
function triggerFileCombine() {
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
const selPaths = Array.from(pane.selected);
let parts = [];
if (selPaths.length > 0) {
parts = selPaths.filter(p => /\.\d{3}$/.test(p) || /\.part\d+/i.test(p));
}
if (parts.length === 0 && pane.entries) {
// Auto-detect part files in current pane
parts = pane.entries
.filter(e => !e.is_dir && (/\.\d{3}$/.test(e.name) || /\.part\d+/i.test(e.name)))
.map(e => e.path);
}
if (parts.length === 0) {
showToast('No multi-part files (.001, .002...) detected in pane', 'info');
openFileCombinerModal([]);
return;
}
openFileCombinerModal(parts);
}
async function triggerNewFile() {
const name = await showPromptDialog({
title: 'Create New File',
subtitle: 'Text document or script',
message: 'Enter name for the new file:',
placeholder: 'notes.txt, script.py, config.yaml...',
confirmText: 'Create & Edit',
cancelText: 'Cancel'
});
if (!name || !name.trim()) return;
const pane = App.panes[App.activePaneIndex];
const newFilePath = joinItemPath(pane.path, name.trim());
try {
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: newFilePath, content: '' })
});
if (resp.ok) {
showToast(`Created '${name.trim()}'`, 'success');
refreshAllPanes();
openEditorWithFile(newFilePath);
} else {
showToast(`Failed to create file: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Error: ${e}`, 'error');
}
}
function triggerDirPermissions(paneIndex = App.activePaneIndex) {
const pane = App.panes[paneIndex];
App.contextItem = {
name: pane.path.split('/').pop() || pane.path,
path: pane.path,
is_dir: true,
mode_octal: '0755',
owner: 'root',
group: 'root'
};
App.contextPaneIndex = paneIndex;
triggerPermissions();
}
function openTerminalInPath(path) {
toggleTerminal(true);
if (termWs && termWs.readyState === WebSocket.OPEN && path) {
termWs.send(`cd "${path.replace(/"/g, '\\"')}" && clear\n`);
}
}
document.addEventListener('click', (e) => {
const menu = document.getElementById('context-menu');
if (!menu || menu.style.display === 'none') return;
const insideMobActions = e.target.closest('#mob-btn-actions');
if (insideMobActions) return;
const insideMenu = e.target.closest('#context-menu');
if (insideMenu) {
const isSubmenuContainer = e.target.closest('.context-submenu');
const isSubmenuHeaderRow = e.target.closest('.context-item.has-submenu');
const colorBtn = e.target.closest('#ctx-btn-color');
const colorBar = e.target.closest('#ctx-color-palette-bar');
const colorDot = e.target.closest('.color-dot-mini') || e.target.closest('.color-dot');
if (isSubmenuHeaderRow && !isSubmenuContainer) {
return;
}
if (colorBtn || (colorBar && !colorDot)) {
return;
}
hideContextMenu();
} else {
hideContextMenu();
}
}, true);
window.addEventListener('popstate', () => {
hideContextMenu();
});
function getSelectedOrCursorPaths() {
const paneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
const pane = App.panes[paneIdx];
if (!pane) return [];
if (App.contextItem) {
if (pane.selected.has(App.contextItem.path)) {
return Array.from(pane.selected);
}
return [App.contextItem.path];
}
if (pane.selected.size > 0) return Array.from(pane.selected);
if (pane.entries[pane.cursorIndex]) return [pane.entries[pane.cursorIndex].path];
return [];
}
function quickTransferToPane(action, targetPaneIdx) {
hideContextMenu();
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
const targetPane = App.panes[targetPaneIdx];
const sourcePaneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
executeTransfer(action, paths, targetPane.path, targetPaneIdx, sourcePaneIdx);
}
function quickTransferToPath(action, destPath) {
hideContextMenu();
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
const sourcePaneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
executeTransfer(action, paths, destPath, -1, sourcePaneIdx);
}
let customDestAction = 'copy';
function openCustomDestModal(action) {
hideContextMenu();
customDestAction = action;
document.getElementById('custom-dest-title').textContent = action === 'move' ? '✂️ Move to Custom Destination' : '📋 Copy to Custom Destination';
document.getElementById('btn-custom-dest-exec').textContent = action === 'move' ? 'Move Items' : 'Copy Items';
showModal('custom-dest-modal');
document.getElementById('custom-dest-input')?.focus();
}
function executeCustomDestTransfer() {
const dest = document.getElementById('custom-dest-input').value.trim();
if (!dest) {
showToast('Please provide a destination directory path', 'warning');
return;
}
const saveFav = document.getElementById('custom-dest-save-fav').checked;
if (saveFav) {
addCustomDestination(dest.split('/').pop() || dest, dest);
}
closeModal('custom-dest-modal');
quickTransferToPath(customDestAction, dest);
}
// ---------------- BOOKMARKS & REMOTE AUTHENTICATION ----------------
let pendingRemoteAuth = null;
function promptRemoteCredentials(paneIndex, targetPath) {
pendingRemoteAuth = { paneIndex, targetPath };
const desc = document.getElementById('remote-auth-desc');
const userIn = document.getElementById('remote-auth-user');
const passIn = document.getElementById('remote-auth-pass');
const safeUri = sanitizeCredentials(targetPath);
if (desc) desc.textContent = `Please enter credentials for remote destination: ${safeUri}`;
if (userIn) userIn.value = App.user?.username || 'root';
if (passIn) passIn.value = '';
showModal('remote-auth-modal');
setTimeout(() => passIn?.focus(), 100);
}
function submitRemoteAuth() {
if (!pendingRemoteAuth) return;
const user = document.getElementById('remote-auth-user')?.value || '';
const pass = document.getElementById('remote-auth-pass')?.value || '';
const remember = document.getElementById('remote-auth-remember')?.checked;
const { paneIndex, targetPath } = pendingRemoteAuth;
closeModal('remote-auth-modal');
pendingRemoteAuth = null;
let authUri = targetPath;
if (targetPath.startsWith('smb://')) {
const clean = targetPath.slice(6);
const parts = clean.split('/');
const host = parts[0].includes('@') ? parts[0].split('@')[1] : parts[0];
const rest = parts.slice(1).join('/');
authUri = `smb://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}/${rest}`;
} else if (targetPath.startsWith('sftp://')) {
const clean = targetPath.slice(7);
const parts = clean.split('/');
const hostPort = parts[0].includes('@') ? parts[0].split('@')[1] : parts[0];
const rest = parts.slice(1).join('/');
authUri = `sftp://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${hostPort}/${rest}`;
}
if (remember) {
App.sessionCredentials = App.sessionCredentials || {};
App.sessionCredentials[targetPath] = { user, pass, authUri };
}
loadPaneDirectory(paneIndex, authUri);
}
function navigateToBookmark(encodedPath, hasPassword, protocol) {
const path = decodeURIComponent(encodedPath);
const paneIndex = App.activePaneIndex;
if (protocol === 'web' || path.startsWith('http://') || path.startsWith('https://')) {
window.open(path, '_blank', 'noopener,noreferrer');
showToast(`🌐 Opened "${path}" in new browser tab`, 'info');
return;
}
if (protocol === 'local' || protocol === 'nfs' || hasPassword || path.startsWith('/')) {
loadPaneDirectory(paneIndex, path);
return;
}
if (App.sessionCredentials && App.sessionCredentials[path]) {
loadPaneDirectory(paneIndex, App.sessionCredentials[path].authUri || path);
return;
}
promptRemoteCredentials(paneIndex, path);
}
async function loadBookmarksList() {
const tbody = document.getElementById('bookmarks-table-body');
if (!tbody) return;
try {
const resp = await fetch('/api/bookmarks', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const list = await resp.json();
App.bookmarks = list;
try { localStorage.setItem('cd_bookmarks_cache', JSON.stringify(list)); } catch (e) {}
if (list.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: var(--text-dim); padding: 16px;">
No custom bookmarks saved yet. Click "+ Add Bookmark" to bookmark local folders, web URLs, or network shares.
</td>
</tr>
`;
return;
}
tbody.innerHTML = list.map(b => {
const isWeb = b.protocol === 'web' || b.path.startsWith('http://') || b.path.startsWith('https://');
const protoBadge = isWeb
? `<span class="badge" style="font-size: 10px; background: rgba(56,189,248,0.15); color: #38bdf8; border: 1px solid rgba(56,189,248,0.3); text-transform: uppercase;">WEB URL</span>`
: `<span class="badge" style="font-size: 10px; text-transform: uppercase;">${escapeHtml(b.protocol)}</span>`;
const safePath = sanitizeCredentials(b.path);
const passBadge = b.has_password ? `<span title="Stored with password" style="color: #22c55e; margin-left: 4px;">🔑</span>` : (isWeb ? '' : `<span title="Prompts on session access" style="color: var(--text-dim); margin-left: 4px;">🔒</span>`);
const jumpLabel = isWeb ? 'Open Tab' : 'Jump';
const jumpIcon = isWeb ? 'external-link' : 'folder';
return `
<tr>
<td><strong>${escapeHtml(b.name)}</strong> ${passBadge}</td>
<td>${protoBadge}</td>
<td><code>${escapeHtml(safePath)}</code></td>
<td>
<div style="display: flex; gap: 4px;">
<button class="btn btn-sm ${isWeb ? 'btn-accent' : ''}" onclick="navigateToBookmark('${encodeURIComponent(b.path)}', ${b.has_password}, '${b.protocol}'); closeModal('settings-modal');" title="${isWeb ? 'Open URL in new tab' : 'Jump to destination'}">
<i data-lucide="${jumpIcon}" style="width: 12px; height: 12px;"></i> ${jumpLabel}
</button>
<button class="btn btn-sm btn-icon btn-danger" onclick="deleteBookmark(${b.id})" title="Delete Bookmark">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
} catch (e) {
console.error('Failed to load bookmarks:', e);
}
}
function addNewBookmark(prefillPath = null, prefillName = null) {
const modal = document.getElementById('bookmark-modal');
if (!modal) return;
const pathInput = document.getElementById('bm-input-path');
const nameInput = document.getElementById('bm-input-name');
const protoSelect = document.getElementById('bm-select-protocol');
const passInput = document.getElementById('bm-input-password');
const rawPath = prefillPath ? decodeURIComponent(prefillPath) : (App.panes[App.activePaneIndex]?.path || '/home/bolt');
if (pathInput) pathInput.value = sanitizeCredentials(rawPath);
let proto = 'local';
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) proto = 'web';
else if (rawPath.startsWith('smb://')) proto = 'smb';
else if (rawPath.startsWith('sftp://')) proto = 'sftp';
else if (rawPath.startsWith('nfs://')) proto = 'nfs';
else if (rawPath.startsWith('webdav://')) proto = 'webdav';
else if (rawPath.startsWith('s3://')) proto = 's3';
else if (rawPath.startsWith('proton://')) proto = 'proton';
if (protoSelect) protoSelect.value = proto;
if (nameInput) {
if (prefillName) {
nameInput.value = prefillName;
} else {
const parts = rawPath.split('/').filter(Boolean);
nameInput.value = parts.pop() || (proto === 'local' ? 'Root' : `${proto.toUpperCase()} Share`);
}
}
if (passInput) passInput.value = '';
onBookmarkProtocolChange();
showModal('bookmark-modal');
setTimeout(() => nameInput?.focus(), 100);
}
function onBookmarkProtocolChange() {
const proto = document.getElementById('bm-select-protocol')?.value;
const passGroup = document.getElementById('bm-password-group');
const webGroup = document.getElementById('bm-web-target-group');
const pathLabel = document.getElementById('bm-label-path');
const pathInput = document.getElementById('bm-input-path');
if (passGroup) {
passGroup.style.display = (proto === 'smb' || proto === 'sftp' || proto === 'webdav' || proto === 's3') ? 'block' : 'none';
}
if (webGroup) {
webGroup.style.display = (proto === 'web') ? 'block' : 'none';
}
if (pathLabel && pathInput) {
if (proto === 'web') {
pathLabel.textContent = 'Web URL / Link:';
if (!pathInput.value || pathInput.value.startsWith('/')) pathInput.placeholder = 'https://github.com/Woofson/brum';
} else if (proto === 'smb') {
pathLabel.textContent = 'SMB Share URI:';
if (!pathInput.value.startsWith('smb://')) pathInput.placeholder = 'smb://192.168.1.100/share';
} else if (proto === 'sftp') {
pathLabel.textContent = 'SFTP Server URI:';
if (!pathInput.value.startsWith('sftp://')) pathInput.placeholder = 'sftp://user@host:22/path';
} else if (proto === 'nfs') {
pathLabel.textContent = 'NFS Export URI:';
if (!pathInput.value.startsWith('nfs://')) pathInput.placeholder = 'nfs://host/export_path';
} else if (proto === 'webdav') {
pathLabel.textContent = 'WebDAV URL:';
if (!pathInput.value.startsWith('webdav://')) pathInput.placeholder = 'webdav://host/remote.php/webdav';
} else if (proto === 's3') {
pathLabel.textContent = 'S3 Bucket URI:';
if (!pathInput.value.startsWith('s3://')) pathInput.placeholder = 's3://bucket-name/folder';
} else if (proto === 'proton') {
pathLabel.textContent = 'Proton Drive URI:';
if (!pathInput.value.startsWith('proton://')) pathInput.placeholder = 'proton:///folder';
} else {
pathLabel.textContent = 'Local Path:';
pathInput.placeholder = '/home/bolt/Documents';
}
}
}
async function saveBookmarkFromModal() {
const name = document.getElementById('bm-input-name')?.value?.trim();
const protocol = document.getElementById('bm-select-protocol')?.value || 'local';
let path = document.getElementById('bm-input-path')?.value?.trim();
const password = document.getElementById('bm-input-password')?.value?.trim() || null;
if (!name || !path) {
showToast('Please enter both bookmark name and target path', 'warning');
return;
}
try {
const resp = await fetch('/api/bookmarks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${App.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, protocol, path, password })
});
if (resp.ok) {
closeModal('bookmark-modal');
showToast(`Bookmark "${name}" saved!`, 'success');
loadBookmarksList();
} else {
const err = await resp.text();
showToast(`Failed to save bookmark: ${err}`, 'error');
}
} catch (e) {
showToast('Network error saving bookmark', 'error');
}
}
async function deleteBookmark(id) {
try {
const resp = await fetch(`/api/bookmarks/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast('Bookmark deleted', 'info');
loadBookmarksList();
} else {
showToast('Failed to delete bookmark', 'error');
}
} catch (e) {
showToast('Error deleting bookmark', 'error');
}
}
async function addCurrentPaneToQuickDest() {
hideContextMenu();
const currentPath = App.panes[App.activePaneIndex]?.path || '/';
addNewBookmark(encodeURIComponent(currentPath));
}
// ---------------- SESSION LOCK & INACTIVITY WATCHER ----------------
let lastUserActivityTime = Date.now();
function lockSession() {
App.isLocked = true;
localStorage.setItem('cd_is_locked', 'true');
document.documentElement.classList.remove('auth-pending-login', 'auth-verifying', 'auth-ready');
document.documentElement.classList.add('auth-pending-lock');
const lockScreen = document.getElementById('session-lock-screen');
const userLabel = document.getElementById('lock-username-label');
const avatarEl = document.getElementById('lock-avatar-thumb');
const passIn = document.getElementById('unlock-password-input');
const errMsg = document.getElementById('unlock-error-msg');
let cachedUser = App.user;
if (!cachedUser) {
try {
cachedUser = JSON.parse(localStorage.getItem('cd_user_info') || 'null');
} catch (_) {}
}
const uname = cachedUser?.nickname || localNick || cachedUser?.username || lastUser || 'Brum User';
const avatar = cachedUser?.avatar_url || localAvatar || '👤';
const userTextEl = document.getElementById('lock-username-text');
const hostSuffixEl = document.getElementById('lock-hostname-suffix');
const cfg = getHostnameBadgeSettings();
const hostStr = cfg.hostname || 'localhost';
if (userTextEl) {
userTextEl.textContent = uname;
} else if (userLabel) {
userLabel.innerHTML = `<span id="lock-username-text">${typeof escapeHtml === 'function' ? escapeHtml(uname) : uname}</span><span id="lock-hostname-suffix" class="lock-hostname-suffix">@${typeof escapeHtml === 'function' ? escapeHtml(hostStr) : hostStr}</span>`;
}
const activeHostSuffix = document.getElementById('lock-hostname-suffix');
if (activeHostSuffix) {
activeHostSuffix.textContent = `@${hostStr}`;
}
updateHostnameBadge();
if (avatarEl) renderAvatarElement(avatarEl, avatar);
if (passIn) passIn.value = '';
if (errMsg) errMsg.style.display = 'none';
if (lockScreen) {
lockScreen.classList.add('active');
if (window.lucide) lucide.createIcons({ root: lockScreen });
}
setTimeout(() => passIn?.focus(), 150);
}
async function submitUnlockSession() {
const passIn = document.getElementById('unlock-password-input');
const errMsg = document.getElementById('unlock-error-msg');
const submitBtn = document.getElementById('btn-submit-unlock');
const pass = passIn?.value || '';
if (!pass) return;
let cachedUser = App.user;
if (!cachedUser) {
try {
cachedUser = JSON.parse(localStorage.getItem('cd_user_info') || 'null');
} catch (_) {}
}
const domUsername = document.getElementById('lock-username-text')?.textContent?.trim();
const lastUsername = localStorage.getItem('cd_last_username') || '';
const uname = cachedUser?.username || domUsername || lastUsername || 'admin';
if (errMsg) errMsg.style.display = 'none';
const origBtnHtml = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i data-lucide="loader" class="spinner" style="width: 14px; height: 14px;"></i> <span>Unlocking...</span>';
if (window.lucide) lucide.createIcons({ root: submitBtn });
}
try {
let unlocked = false;
const authHeaders = { 'Content-Type': 'application/json' };
if (App.token) {
authHeaders['Authorization'] = `Bearer ${App.token}`;
}
// 1. Direct unified unlock endpoint (fast path: accepts active or expired token, or username)
const resp = await fetch('/api/auth/unlock', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ password: pass, username: uname })
});
if (resp.ok) {
const data = await resp.json();
if (data.token) {
App.token = data.token;
localStorage.setItem('cd_token', data.token);
try {
document.cookie = `cd_token=${encodeURIComponent(data.token)}; path=/; SameSite=Lax`;
} catch (_) {}
}
if (data.user) {
App.user = data.user;
const localAvatar = localStorage.getItem('cd_local_avatar');
const localNick = localStorage.getItem('cd_local_nickname');
if (localAvatar && !App.user.avatar_url) App.user.avatar_url = localAvatar;
if (localNick && !App.user.nickname) App.user.nickname = localNick;
try {
localStorage.setItem('cd_user_info', JSON.stringify({
username: data.user.username,
nickname: data.user.nickname || localNick,
avatar_url: data.user.avatar_url || localAvatar
}));
localStorage.setItem('cd_last_username', data.user.username);
} catch (_) {}
}
unlocked = true;
} else if (resp.status === 404 || resp.status === 400) {
// 2. Fallback to /api/auth/login if /api/auth/unlock endpoint is not available
const loginResp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: uname, password: pass })
});
if (loginResp.ok) {
const data = await loginResp.json();
App.token = data.token;
App.user = data.user;
const localAvatar = localStorage.getItem('cd_local_avatar');
const localNick = localStorage.getItem('cd_local_nickname');
if (localAvatar && !App.user.avatar_url) App.user.avatar_url = localAvatar;
if (localNick && !App.user.nickname) App.user.nickname = localNick;
localStorage.setItem('cd_token', data.token);
try {
document.cookie = `cd_token=${encodeURIComponent(data.token)}; path=/; SameSite=Lax`;
localStorage.setItem('cd_user_info', JSON.stringify({
username: data.user?.username,
nickname: data.user?.nickname || localNick,
avatar_url: data.user?.avatar_url || localAvatar
}));
localStorage.setItem('cd_last_username', data.user?.username);
} catch (_) {}
unlocked = true;
}
}
if (unlocked) {
App.isLocked = false;
localStorage.removeItem('cd_is_locked');
document.documentElement.classList.remove('auth-pending-lock', 'auth-pending-login', 'auth-verifying');
document.documentElement.classList.add('auth-ready');
lastUserActivityTime = Date.now();
const lockScreen = document.getElementById('session-lock-screen');
if (lockScreen) {
lockScreen.classList.remove('active');
}
if (passIn) passIn.value = '';
if (errMsg) errMsg.style.display = 'none';
updateHeaderProfile(App.user);
applyUserHomeToPanes();
renderAllPanes();
showToast('Session unlocked. Welcome back!', 'success');
} else {
if (errMsg) {
errMsg.textContent = 'Invalid password. Please try again.';
errMsg.style.display = 'block';
}
passIn?.select();
}
} catch (e) {
if (errMsg) {
errMsg.textContent = 'Connection error during unlock.';
errMsg.style.display = 'block';
}
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = origBtnHtml || '<i data-lucide="unlock" style="width: 14px; height: 14px;"></i> <span>Unlock Session</span>';
if (window.lucide) lucide.createIcons({ root: submitBtn });
}
}
}
function initInactivityTracker() {
const updateActivity = () => {
if (!App.isLocked) {
lastUserActivityTime = Date.now();
}
};
window.addEventListener('mousemove', updateActivity, { passive: true });
window.addEventListener('keydown', updateActivity, { passive: true });
window.addEventListener('touchstart', updateActivity, { passive: true });
window.addEventListener('pointerdown', updateActivity, { passive: true });
window.addEventListener('click', updateActivity, { passive: true });
window.addEventListener('scroll', updateActivity, { passive: true });
setInterval(() => {
if (!App.token) return;
const settings = App.securitySettings || {
auto_lock_enabled: true,
auto_lock_minutes: 15,
session_timeout_hours: 8,
lock_prevented_by_tasks: true
};
const idleMinutes = (Date.now() - lastUserActivityTime) / (60 * 1000);
const idleHours = idleMinutes / 60;
// 1. Auto-Lock when not yet locked
if (!App.isLocked && settings.auto_lock_enabled && idleMinutes >= settings.auto_lock_minutes) {
const isTaskRunning = lastKnownTasksList && lastKnownTasksList.some(t => t.status === 'running');
if (isTaskRunning && settings.lock_prevented_by_tasks) {
return;
}
lockSession();
}
// 2. Session Timeout Expiration (even if locked)
if (idleHours >= settings.session_timeout_hours) {
const isTaskRunning = lastKnownTasksList && lastKnownTasksList.some(t => t.status === 'running');
if (!isTaskRunning) {
if (App.isLocked) {
const errMsg = document.getElementById('unlock-error-msg');
const passIn = document.getElementById('unlock-password-input');
if (errMsg) {
errMsg.innerHTML = `⚠️ <strong>Session timed out due to inactivity.</strong> Please log in again.<br><button type="button" class="btn btn-sm btn-accent" style="margin-top: 8px; width: 100%; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 6px;" onclick="logout()"><i data-lucide="log-in" style="width: 14px; height: 14px;"></i> Go to Login</button>`;
errMsg.style.display = 'block';
if (window.lucide) lucide.createIcons({ root: errMsg });
}
if (passIn) passIn.disabled = true;
} else {
logout();
}
}
}
}, 15000);
}
async function loadAdminSecuritySettings() {
try {
const resp = await fetch('/api/auth/security-settings', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const s = await resp.json();
App.securitySettings = s;
const elLock = document.getElementById('setting-autolock-enabled');
const elMins = document.getElementById('setting-autolock-minutes');
const elHours = document.getElementById('setting-session-hours');
const elPrevent = document.getElementById('setting-autolock-prevent-tasks');
if (elLock) elLock.checked = s.auto_lock_enabled;
if (elMins) elMins.value = String(s.auto_lock_minutes);
if (elHours) elHours.value = String(s.session_timeout_hours);
if (elPrevent) elPrevent.checked = s.lock_prevented_by_tasks;
}
} catch (e) {
console.warn('Failed to load security settings:', e);
}
}
async function saveAdminSecuritySettings() {
const auto_lock_enabled = document.getElementById('setting-autolock-enabled')?.checked ?? true;
const auto_lock_minutes = parseInt(document.getElementById('setting-autolock-minutes')?.value || '15', 10);
const session_timeout_hours = parseInt(document.getElementById('setting-session-hours')?.value || '8', 10);
const lock_prevented_by_tasks = document.getElementById('setting-autolock-prevent-tasks')?.checked ?? true;
const payload = {
auto_lock_enabled,
auto_lock_minutes,
session_timeout_hours,
lock_prevented_by_tasks
};
try {
const resp = await fetch('/api/auth/security-settings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${App.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (resp.ok) {
App.securitySettings = payload;
showToast('Security and auto-lock policies saved!', 'success');
} else {
showToast('Failed to save security settings', 'error');
}
} catch (e) {
showToast('Network error saving security settings', 'error');
}
}
function setupEventListeners() {
document.getElementById('layout-1')?.addEventListener('click', () => switchLayout('layout-single'));
document.getElementById('layout-2v')?.addEventListener('click', () => switchLayout('layout-dual-vertical'));
document.getElementById('layout-2h')?.addEventListener('click', () => switchLayout('layout-dual-horizontal'));
document.getElementById('layout-3')?.addEventListener('click', () => switchLayout('layout-triple'));
document.getElementById('layout-3s')?.addEventListener('click', () => switchLayout('layout-triple-stacked'));
document.getElementById('layout-4')?.addEventListener('click', () => switchLayout('layout-quad'));
updateActiveLayoutUI(App.layout);
document.getElementById('paranoid-toggle')?.addEventListener('click', () => {
App.paranoidMode = !App.paranoidMode;
updateParanoidBadge();
});
document.getElementById('btn-custom-dest-exec')?.addEventListener('click', executeCustomDestTransfer);
document.getElementById('theme-selector')?.addEventListener('change', (e) => {
applyTheme(e.target.value);
});
document.getElementById('login-form')?.addEventListener('submit', (e) => {
e.preventDefault();
handleLoginSubmit();
});
document.getElementById('login-username')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('login-password')?.focus();
}
});
document.getElementById('btn-logout')?.addEventListener('click', () => {
localStorage.removeItem('cd_token');
try {
document.cookie = 'cd_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
} catch (e) {}
location.reload();
});
setupTouchGestures();
}
function switchLayout(layoutName) {
App.layout = layoutName;
setLayoutForViewport(layoutName);
updateActiveLayoutUI(layoutName);
const visibleCount = getVisiblePaneCount();
if (App.activePaneIndex >= visibleCount) {
App.activePaneIndex = visibleCount - 1;
}
// If terminal was docked in a pane that is now hidden, migrate terminal to active visible pane
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'terminal' && idx >= visibleCount) {
p.dockedTool = null;
localStorage.removeItem(`cd_pane_docked_${idx}`);
if (App.panes[App.activePaneIndex]) {
App.panes[App.activePaneIndex].dockedTool = 'terminal';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'terminal');
}
}
});
renderAllPanes();
applyAllColumnWidths();
queueSaveUserPreferencesToServer();
}
function updateActiveLayoutUI(layoutName) {
const map = {
'layout-single': 'layout-1',
'layout-dual-vertical': 'layout-2v',
'layout-dual-horizontal': 'layout-2h',
'layout-triple': 'layout-3',
'layout-triple-stacked': 'layout-3s',
'layout-quad': 'layout-4'
};
['layout-1', 'layout-2v', 'layout-2h', 'layout-3', 'layout-3s', 'layout-4'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
});
const activeId = map[layoutName] || 'layout-2v';
const activeEl = document.getElementById(activeId);
if (activeEl) activeEl.classList.add('active');
}
function updateParanoidBadge() {
const badge = document.getElementById('paranoid-toggle');
if (badge) {
if (App.paranoidMode) {
badge.classList.remove('disabled');
const span = badge.querySelector('span');
if (span) span.textContent = 'PARANOID MODE: ON';
} else {
badge.classList.add('disabled');
const span = badge.querySelector('span');
if (span) span.textContent = 'PARANOID MODE: OFF';
}
}
const shieldBtn = document.getElementById('profile-paranoid-shield');
if (shieldBtn) {
if (App.paranoidMode) {
shieldBtn.classList.add('active');
shieldBtn.title = 'Paranoid Mode: Active (Click for Settings)';
} else {
shieldBtn.classList.remove('active');
shieldBtn.title = 'Paranoid Mode: Disabled (Click for Settings)';
}
}
}
function openParanoidSettings() {
const profileMenu = document.getElementById('profile-dropdown-menu');
if (profileMenu) profileMenu.classList.remove('active');
openSettingsModal();
switchSettingsTab('tab-security');
}
function applyBorderSettings(borderWidth, ringStyle, borderAngle, skipSync = false) {
if (borderWidth !== undefined && borderWidth !== null) {
localStorage.setItem('cd_border_width', borderWidth);
} else {
borderWidth = localStorage.getItem('cd_border_width') || '1px';
}
if (ringStyle !== undefined && ringStyle !== null) {
localStorage.setItem('cd_ring_style', ringStyle);
} else {
ringStyle = localStorage.getItem('cd_ring_style') || 'subtle';
}
if (borderAngle !== undefined && borderAngle !== null) {
localStorage.setItem('cd_border_angle', borderAngle);
} else {
borderAngle = localStorage.getItem('cd_border_angle') || '45deg';
}
const root = document.documentElement;
root.style.setProperty('--pane-border-width', borderWidth);
root.style.setProperty('--pane-border-angle', borderAngle);
if (ringStyle === 'none') {
root.style.setProperty('--pane-active-ring-width', '0px');
} else if (ringStyle === 'bold') {
root.style.setProperty('--pane-active-ring-width', '2px');
} else if (ringStyle === 'glow') {
root.style.setProperty('--pane-active-ring-width', '1.5px');
} else { // 'subtle' default
root.style.setProperty('--pane-active-ring-width', '1px');
}
const borderSelect = document.getElementById('setting-border-width');
if (borderSelect) borderSelect.value = borderWidth;
const ringSelect = document.getElementById('setting-ring-style');
if (ringSelect) ringSelect.value = ringStyle;
const angleSelect = document.getElementById('setting-border-angle');
if (angleSelect) angleSelect.value = borderAngle;
if (typeof applyPaneColors === 'function') {
applyPaneColors();
}
if (!skipSync) {
queueSaveUserPreferencesToServer();
}
}
function randomizeLoginBackground() {
const modal = document.getElementById('login-modal');
if (!modal) return;
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const randFloat = (min, max) => (Math.random() * (max - min) + min).toFixed(2);
const x1 = randInt(10, 35), y1 = randInt(10, 35);
const x2 = randInt(65, 90), y2 = randInt(65, 90);
const x3 = randInt(35, 65), y3 = randInt(35, 65);
const angle = randInt(125, 160);
const op1 = randFloat(0.12, 0.20);
const op2 = randFloat(0.09, 0.16);
const op3 = randFloat(0.05, 0.10);
const rx1 = randInt(60, 80), ry1 = randInt(45, 65);
const rx2 = randInt(55, 75), ry2 = randInt(40, 60);
const circleSize = randInt(450, 650);
modal.style.backgroundImage = `
radial-gradient(ellipse ${rx1}% ${ry1}% at ${x1}% ${y1}%, rgba(245, 158, 11, ${op1}) 0%, transparent 70%),
radial-gradient(ellipse ${rx2}% ${ry2}% at ${x2}% ${y2}%, rgba(217, 119, 6, ${op2}) 0%, transparent 65%),
radial-gradient(circle ${circleSize}px at ${x3}% ${y3}%, rgba(120, 53, 15, ${op3}) 0%, transparent 100%),
linear-gradient(${angle}deg, #0e0e11 0%, #15151a 45%, #181512 100%)
`;
}
function applyTheme(themeId, skipSync = false) {
localStorage.setItem('cd_theme', themeId);
const sel = document.getElementById('theme-selector');
const selSettings = document.getElementById('settings-theme-selector');
if (sel) sel.value = themeId;
if (selSettings) selSettings.value = themeId;
const root = document.documentElement;
root.setAttribute('data-theme', themeId);
// Update active state on visual swatch cards
document.querySelectorAll('.theme-swatch-card').forEach(card => {
if (card.id === `theme-card-${themeId}`) card.classList.add('active');
else card.classList.remove('active');
});
if (!skipSync) {
queueSaveUserPreferencesToServer();
}
// 1. Check dynamic themes loaded from App.config.themes.themes (built-in + ~/.config/brum/themes/)
const customTheme = App.config?.themes?.themes?.find(t => t.id === themeId);
if (customTheme) {
root.style.setProperty('--bg-dark', customTheme.bg_dark);
root.style.setProperty('--bg-panel', customTheme.bg_panel);
root.style.setProperty('--bg-header', customTheme.bg_active || customTheme.bg_panel);
root.style.setProperty('--bg-active', customTheme.bg_active);
root.style.setProperty('--border', customTheme.border);
root.style.setProperty('--accent', customTheme.accent);
root.style.setProperty('--accent-hover', customTheme.accent_hover || customTheme.accent);
root.style.setProperty('--text-main', customTheme.text_main);
root.style.setProperty('--text-muted', customTheme.text_muted);
return;
}
if (themeId === 'zink') {
root.style.setProperty('--bg-dark', '#fafafa');
root.style.setProperty('--bg-panel', '#ffffff');
root.style.setProperty('--bg-header', '#f4f4f5');
root.style.setProperty('--bg-active', '#e4e4e7');
root.style.setProperty('--bg-hover', '#ebecee');
root.style.setProperty('--bg-selected', 'rgba(245, 158, 11, 0.14)');
root.style.setProperty('--border', '#d4d4d8');
root.style.setProperty('--border-focus', '#d97706');
root.style.setProperty('--accent', '#d97706');
root.style.setProperty('--accent-hover', '#b45309');
root.style.setProperty('--accent-dark', '#92400e');
root.style.setProperty('--accent-subtle', 'rgba(245, 158, 11, 0.10)');
root.style.setProperty('--text-main', '#18181b');
root.style.setProperty('--text-muted', '#52525b');
root.style.setProperty('--text-dim', '#71717a');
root.style.setProperty('--danger', '#dc2626');
root.style.setProperty('--success', '#059669');
root.style.setProperty('--info', '#0284c7');
root.style.setProperty('--archive', '#db2777');
} else if (themeId === 'gruvbox') {
root.style.setProperty('--bg-dark', '#1d2021');
root.style.setProperty('--bg-panel', '#282828');
root.style.setProperty('--bg-header', '#3c3836');
root.style.setProperty('--bg-active', '#504945');
root.style.setProperty('--border', '#504945');
root.style.setProperty('--accent', '#fabd2f');
root.style.setProperty('--accent-hover', '#fe8019');
root.style.setProperty('--text-main', '#ebdbb2');
root.style.setProperty('--text-muted', '#a89984');
} else if (themeId === 'catppuccin-mocha') {
root.style.setProperty('--bg-dark', '#181825');
root.style.setProperty('--bg-panel', '#1e1e2e');
root.style.setProperty('--bg-header', '#313244');
root.style.setProperty('--bg-active', '#45475a');
root.style.setProperty('--border', '#45475a');
root.style.setProperty('--accent', '#cba6f7');
root.style.setProperty('--accent-hover', '#f5c2e7');
root.style.setProperty('--text-main', '#cdd6f4');
root.style.setProperty('--text-muted', '#a6adc8');
} else if (themeId === 'catppuccin-latte') {
root.style.setProperty('--bg-dark', '#dce0e8');
root.style.setProperty('--bg-panel', '#eff1f5');
root.style.setProperty('--bg-header', '#e6e9ef');
root.style.setProperty('--bg-active', '#ccd0da');
root.style.setProperty('--border', '#bcc0cc');
root.style.setProperty('--accent', '#8839ef');
root.style.setProperty('--accent-hover', '#1e66f5');
root.style.setProperty('--text-main', '#4c4f69');
root.style.setProperty('--text-muted', '#6c6f85');
} else if (themeId === 'tokyo-night') {
root.style.setProperty('--bg-dark', '#16161e');
root.style.setProperty('--bg-panel', '#1a1b26');
root.style.setProperty('--bg-header', '#24283b');
root.style.setProperty('--bg-active', '#292e42');
root.style.setProperty('--border', '#3b4261');
root.style.setProperty('--accent', '#7aa2f7');
root.style.setProperty('--accent-hover', '#7dcfff');
root.style.setProperty('--text-main', '#c0caf5');
root.style.setProperty('--text-muted', '#9aa5ce');
} else if (themeId === 'monokai') {
root.style.setProperty('--bg-dark', '#1e1f1c');
root.style.setProperty('--bg-panel', '#272822');
root.style.setProperty('--bg-header', '#3e3d32');
root.style.setProperty('--bg-active', '#49483e');
root.style.setProperty('--border', '#49483e');
root.style.setProperty('--accent', '#ffd866');
root.style.setProperty('--accent-hover', '#a9dc76');
root.style.setProperty('--text-main', '#f8f8f2');
root.style.setProperty('--text-muted', '#939293');
} else if (themeId === 'solarized-dark') {
root.style.setProperty('--bg-dark', '#00212b');
root.style.setProperty('--bg-panel', '#002b36');
root.style.setProperty('--bg-header', '#073642');
root.style.setProperty('--bg-active', '#0a4250');
root.style.setProperty('--border', '#586e75');
root.style.setProperty('--accent', '#268bd2');
root.style.setProperty('--accent-hover', '#2aa198');
root.style.setProperty('--text-main', '#839496');
root.style.setProperty('--text-muted', '#657b83');
} else if (themeId === 'ayu-dark') {
root.style.setProperty('--bg-dark', '#0b0e14');
root.style.setProperty('--bg-panel', '#0f1419');
root.style.setProperty('--bg-header', '#1f2430');
root.style.setProperty('--bg-active', '#242b38');
root.style.setProperty('--border', '#252e37');
root.style.setProperty('--accent', '#e6b450');
root.style.setProperty('--accent-hover', '#ffb454');
root.style.setProperty('--text-main', '#e6e1cf');
root.style.setProperty('--text-muted', '#707a8c');
} else if (themeId === 'nord') {
root.style.setProperty('--bg-dark', '#242933');
root.style.setProperty('--bg-panel', '#2e3440');
root.style.setProperty('--bg-header', '#3b4252');
root.style.setProperty('--bg-active', '#434c5e');
root.style.setProperty('--border', '#4c566a');
root.style.setProperty('--accent', '#88c0d0');
root.style.setProperty('--accent-hover', '#81a1c1');
root.style.setProperty('--text-main', '#eceff4');
root.style.setProperty('--text-muted', '#d8dee9');
} else if (themeId === 'dracula') {
root.style.setProperty('--bg-dark', '#1e1f29');
root.style.setProperty('--bg-panel', '#282a36');
root.style.setProperty('--bg-header', '#44475a');
root.style.setProperty('--bg-active', '#6272a4');
root.style.setProperty('--border', '#6272a4');
root.style.setProperty('--accent', '#bd93f9');
root.style.setProperty('--accent-hover', '#ff79c6');
root.style.setProperty('--text-main', '#f8f8f2');
root.style.setProperty('--text-muted', '#6272a4');
} else if (themeId === 'midnight-blue') {
root.style.setProperty('--bg-dark', '#000044');
root.style.setProperty('--bg-panel', '#000088');
root.style.setProperty('--bg-header', '#000066');
root.style.setProperty('--bg-active', '#0000aa');
root.style.setProperty('--border', '#00aaff');
root.style.setProperty('--accent', '#00ffff');
root.style.setProperty('--accent-hover', '#ffffff');
root.style.setProperty('--text-main', '#ffffff');
root.style.setProperty('--text-muted', '#a0a0ff');
} else if (themeId === 'skumring') {
root.style.setProperty('--bg-dark', '#0a0e14');
root.style.setProperty('--bg-panel', '#111822');
root.style.setProperty('--bg-header', '#1e2c3d');
root.style.setProperty('--bg-active', '#1e2c3d');
root.style.setProperty('--border', '#243347');
root.style.setProperty('--accent', '#38bdf8');
root.style.setProperty('--accent-hover', '#7dd3fc');
root.style.setProperty('--text-main', '#e6edf3');
root.style.setProperty('--text-muted', '#8b9bb4');
} else if (themeId === 'demring') {
root.style.setProperty('--bg-dark', '#eef2f6');
root.style.setProperty('--bg-panel', '#f7fafc');
root.style.setProperty('--bg-header', '#cbd5e1');
root.style.setProperty('--bg-active', '#cbd5e1');
root.style.setProperty('--border', '#cbd5e1');
root.style.setProperty('--accent', '#0e7490');
root.style.setProperty('--accent-hover', '#155e75');
root.style.setProperty('--text-main', '#0f172a');
root.style.setProperty('--text-muted', '#475569');
} else if (themeId === 'trollnatt') {
root.style.setProperty('--bg-dark', '#0b100d');
root.style.setProperty('--bg-panel', '#121914');
root.style.setProperty('--bg-header', '#222f26');
root.style.setProperty('--bg-active', '#222f26');
root.style.setProperty('--border', '#25342a');
root.style.setProperty('--accent', '#4ade80');
root.style.setProperty('--accent-hover', '#86efac');
root.style.setProperty('--text-main', '#edf4ee');
root.style.setProperty('--text-muted', '#93a797');
} else if (themeId === 'myrtaake') {
root.style.setProperty('--bg-dark', '#edf2ee');
root.style.setProperty('--bg-panel', '#f5f9f6');
root.style.setProperty('--bg-header', '#cad5cc');
root.style.setProperty('--bg-active', '#cad5cc');
root.style.setProperty('--border', '#cbd7cd');
root.style.setProperty('--accent', '#15803d');
root.style.setProperty('--accent-hover', '#166534');
root.style.setProperty('--text-main', '#0f1712');
root.style.setProperty('--text-muted', '#49594d');
} else if (themeId === 'bergtatt') {
root.style.setProperty('--bg-dark', '#0a0c0f');
root.style.setProperty('--bg-panel', '#11141a');
root.style.setProperty('--bg-header', '#222935');
root.style.setProperty('--bg-active', '#222935');
root.style.setProperty('--border', '#262e3d');
root.style.setProperty('--accent', '#d9a042');
root.style.setProperty('--accent-hover', '#f1b759');
root.style.setProperty('--text-main', '#e8e2d8');
root.style.setProperty('--text-muted', '#8e8d89');
} else if (themeId === 'soria-moria') {
root.style.setProperty('--bg-dark', '#ebe5dc');
root.style.setProperty('--bg-panel', '#f5f0e6');
root.style.setProperty('--bg-header', '#cbbead');
root.style.setProperty('--bg-active', '#cbbead');
root.style.setProperty('--border', '#c6bbaa');
root.style.setProperty('--accent', '#b87a1f');
root.style.setProperty('--accent-hover', '#8f5a0e');
root.style.setProperty('--text-main', '#1c1815');
root.style.setProperty('--text-muted', '#5d554a');
} else if (themeId === 'pestanatt') {
root.style.setProperty('--bg-dark', '#0b090a');
root.style.setProperty('--bg-panel', '#141011');
root.style.setProperty('--bg-header', '#261e20');
root.style.setProperty('--bg-active', '#261e20');
root.style.setProperty('--border', '#2b2023');
root.style.setProperty('--accent', '#dc2626');
root.style.setProperty('--accent-hover', '#ef4444');
root.style.setProperty('--text-main', '#e6dede');
root.style.setProperty('--text-muted', '#948285');
} else if (themeId === 'sotslette') {
root.style.setProperty('--bg-dark', '#ece6dc');
root.style.setProperty('--bg-panel', '#f5f0e6');
root.style.setProperty('--bg-header', '#cec3b2');
root.style.setProperty('--bg-active', '#cec3b2');
root.style.setProperty('--border', '#c7bcab');
root.style.setProperty('--accent', '#991b1b');
root.style.setProperty('--accent-hover', '#b91c1c');
root.style.setProperty('--text-main', '#1c1517');
root.style.setProperty('--text-muted', '#5c4f52');
} else {
// Woofson Amber Charcoal Default
root.style.setProperty('--bg-dark', '#121214');
root.style.setProperty('--bg-panel', '#18181b');
root.style.setProperty('--bg-header', '#202024');
root.style.setProperty('--bg-active', '#27272a');
root.style.setProperty('--bg-hover', '#323238');
root.style.setProperty('--bg-selected', 'rgba(245, 158, 11, 0.18)');
root.style.setProperty('--border', '#3f3f46');
root.style.setProperty('--border-focus', '#f59e0b');
root.style.setProperty('--accent', '#f59e0b');
root.style.setProperty('--accent-hover', '#fbbf24');
root.style.setProperty('--accent-dark', '#d97706');
root.style.setProperty('--accent-subtle', 'rgba(245, 158, 11, 0.12)');
root.style.setProperty('--text-main', '#f4f4f5');
root.style.setProperty('--text-muted', '#a1a1aa');
root.style.setProperty('--text-dim', '#71717a');
root.style.setProperty('--danger', '#ef4444');
root.style.setProperty('--success', '#10b981');
root.style.setProperty('--info', '#38bdf8');
root.style.setProperty('--archive', '#f472b6');
}
}
async function handleLoginSubmit() {
if (window._isSubmittingLogin) return;
window._isSubmittingLogin = true;
const uInput = document.getElementById('login-username');
const pInput = document.getElementById('login-password');
const err = document.getElementById('login-error');
const submitBtn = document.getElementById('btn-submit-login') || document.querySelector('.login-submit-btn');
const u = uInput?.value.trim() || '';
const p = pInput?.value || '';
if (!u || !p) {
window._isSubmittingLogin = false;
if (err) {
err.style.display = 'block';
err.textContent = 'Please enter both username and password.';
}
if (!u) uInput?.focus();
else pInput?.focus();
return;
}
if (submitBtn) submitBtn.disabled = true;
let loginData = null;
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
if (resp.ok) {
loginData = await resp.json();
} else {
let errMsg = 'Invalid credentials. Please try again.';
try {
const errJson = await resp.json();
if (errJson && errJson.message) errMsg = errJson.message;
} catch (_) {}
if (err) {
err.style.display = 'block';
err.textContent = errMsg;
}
if (pInput) {
pInput.value = '';
pInput.focus();
}
return;
}
} catch (netErr) {
if (err) {
err.style.display = 'block';
err.textContent = 'Connection to server failed. Please verify that Brum backend is running.';
}
return;
} finally {
window._isSubmittingLogin = false;
if (submitBtn) submitBtn.disabled = false;
}
// Handle post-login rendering and hydration outside the network catch block
if (loginData && loginData.token) {
App.token = loginData.token;
App.user = loginData.user;
updateHeaderProfile(App.user);
localStorage.setItem('cd_token', loginData.token);
try {
document.cookie = `cd_token=${encodeURIComponent(loginData.token)}; path=/; SameSite=Lax`;
localStorage.setItem('cd_user_info', JSON.stringify({
username: loginData.user?.username,
nickname: loginData.user?.nickname,
avatar_url: loginData.user?.avatar_url
}));
if (loginData.user?.username) {
localStorage.setItem('cd_last_username', loginData.user.username);
}
} catch (e) {}
localStorage.removeItem('cd_is_locked');
App.isLocked = false;
document.documentElement.classList.remove('auth-pending-login', 'auth-pending-lock', 'auth-verifying');
document.documentElement.classList.add('auth-ready');
hideModal('login-modal');
const modalEl = document.getElementById('login-modal');
if (modalEl) {
modalEl.classList.remove('active');
modalEl.style.display = 'none';
}
if (err) err.style.display = 'none';
if (pInput) pInput.value = '';
try {
applyUserHomeToPanes(true);
renderAllPanes();
restoreTerminalState();
} catch (renderErr) {
console.error('Post-login render error:', renderErr);
}
Promise.allSettled([
loadConfig(),
loadSystemUsersGroups(),
loadAdminSecuritySettings(),
loadAllFileTags(),
loadUserPreferencesFromServer(),
loadInstalledChewToys(false)
]);
}
}
async function loadOidcConfig() {
try {
const resp = await fetch('/api/auth/oidc/config').catch(() => null);
if (resp && resp.ok) {
const data = await resp.json();
App.oidcConfig = data;
const ssoSec = document.getElementById('login-sso-section');
const btnText = document.getElementById('login-sso-btn-text');
const ssoIcon = document.getElementById('login-sso-icon');
if (data.enabled && ssoSec) {
ssoSec.style.display = 'block';
if (btnText && data.provider_name) {
btnText.textContent = `Sign in with ${data.provider_name}`;
}
if (ssoIcon && data.button_icon) {
ssoIcon.setAttribute('data-lucide', data.button_icon);
if (window.lucide) lucide.createIcons();
}
// Auto-redirect if force_sso_only is enabled and user is not authenticated and no error parameter in URL
if (data.force_sso_only && !App.user && !App.token && !new URLSearchParams(window.location.search).get('error')) {
triggerOidcLogin();
}
} else if (ssoSec) {
ssoSec.style.display = 'none';
}
}
} catch (e) {
console.warn('Failed to load OIDC configuration:', e);
}
}
function triggerOidcLogin() {
window.location.href = App.oidcConfig?.login_url || '/api/auth/oidc/login';
}
function openHelpModal() {
showModal('settings-modal');
switchSettingsTab('tab-keys');
}
function openSettingsModal() {
const startupSettings = getStartupSettings();
const startupModeSel = document.getElementById('setting-startup-mode');
if (startupModeSel) {
startupModeSel.value = startupSettings.mode;
const customWrapper = document.getElementById('wrapper-startup-custom-presets');
if (customWrapper) customWrapper.style.display = (startupSettings.mode === 'custom') ? 'block' : 'none';
}
const startupRemoteSel = document.getElementById('setting-startup-remote-fallback');
if (startupRemoteSel) {
startupRemoteSel.value = startupSettings.remoteFallback;
}
for (let i = 0; i < 4; i++) {
const input = document.getElementById(`setting-startup-custom-path-${i}`);
if (input) {
input.value = startupSettings.customPaths[i] || '';
}
}
const rememberChk = document.getElementById('setting-remember-paths');
if (rememberChk) {
rememberChk.checked = (startupSettings.mode === 'restore');
}
const dblclickCheckbox = document.getElementById('setting-dblclick-up');
if (dblclickCheckbox) {
dblclickCheckbox.checked = App.dblclickUpDir;
dblclickCheckbox.onchange = (e) => {
App.dblclickUpDir = e.target.checked;
localStorage.setItem('cd_dblclick_up', e.target.checked);
queueSaveUserPreferencesToServer();
};
}
const parentDirCheckbox = document.getElementById('setting-show-parent-dir');
if (parentDirCheckbox) {
parentDirCheckbox.checked = App.showParentDir;
parentDirCheckbox.onchange = (e) => {
App.showParentDir = e.target.checked;
localStorage.setItem('cd_show_parent_dir', e.target.checked);
renderAllPanes();
queueSaveUserPreferencesToServer();
};
}
const autoOpenTasksCheckbox = document.getElementById('setting-auto-open-tasks');
if (autoOpenTasksCheckbox) {
autoOpenTasksCheckbox.checked = App.autoOpenTasks;
autoOpenTasksCheckbox.onchange = (e) => {
App.autoOpenTasks = e.target.checked;
localStorage.setItem('cd_auto_open_tasks', e.target.checked);
queueSaveUserPreferencesToServer();
};
}
const fkeysCheckbox = document.getElementById('setting-show-fkeys');
if (fkeysCheckbox) {
fkeysCheckbox.checked = App.showFKeyBar !== false && localStorage.getItem('cd_show_fkeys') !== 'false';
}
const mediaStopCheckbox = document.getElementById('setting-media-stop-on-close');
if (mediaStopCheckbox) {
mediaStopCheckbox.checked = localStorage.getItem('cd_media_stop_on_close') !== 'false';
}
const paneTabsModeSel = document.getElementById('setting-pane-tabs-mode');
if (paneTabsModeSel) {
paneTabsModeSel.value = localStorage.getItem('cd_pane_tabs_mode') || 'pc_only';
}
const dotfilesCheckbox = document.getElementById('setting-show-hidden');
if (dotfilesCheckbox) {
dotfilesCheckbox.checked = App.panes[0]?.showHidden || false;
}
const copyActionSel = document.getElementById('setting-default-copy-action');
if (copyActionSel) {
copyActionSel.value = getDefaultCopyAction();
}
const themeSel = document.getElementById('settings-theme-selector');
if (themeSel) {
themeSel.value = localStorage.getItem('cd_theme') || 'amber-charcoal';
}
const borderSel = document.getElementById('setting-border-width');
if (borderSel) {
borderSel.value = localStorage.getItem('cd_border_width') || '1px';
}
const ringSel = document.getElementById('setting-ring-style');
if (ringSel) {
ringSel.value = localStorage.getItem('cd_ring_style') || 'subtle';
}
const angleSel = document.getElementById('setting-border-angle');
if (angleSel) {
angleSel.value = localStorage.getItem('cd_border_angle') || '45deg';
}
const decCheckbox = document.getElementById('setting-window-decorations');
if (decCheckbox) {
const saved = localStorage.getItem('cd_window_decorations');
decCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.ui?.window_decorations !== false);
}
const decDesktopCheckbox = document.getElementById('setting-desktop-decorations');
if (decDesktopCheckbox) {
const saved = localStorage.getItem('cd_window_decorations');
decDesktopCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.ui?.window_decorations !== false);
}
const minTrayCheckbox = document.getElementById('setting-minimize-tray');
if (minTrayCheckbox) {
const saved = localStorage.getItem('cd_minimize_tray');
minTrayCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.minimize_to_tray !== false);
}
const startMinCheckbox = document.getElementById('setting-start-minimized');
if (startMinCheckbox) {
const saved = localStorage.getItem('cd_start_minimized');
startMinCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.desktop?.start_minimized === true);
}
const hotkeyInput = document.getElementById('setting-global-hotkey');
if (hotkeyInput && App.config?.desktop?.global_summon_hotkey) {
hotkeyInput.value = App.config.desktop.global_summon_hotkey;
}
const globalRefreshCheckbox = document.getElementById('setting-show-global-refresh');
if (globalRefreshCheckbox) {
const saved = localStorage.getItem('cd_show_global_refresh');
globalRefreshCheckbox.checked = saved !== null ? (saved === 'true') : (App.config?.ui?.show_global_refresh === true);
}
const hostnameBadgeCheckbox = document.getElementById('setting-show-hostname-badge');
if (hostnameBadgeCheckbox) {
const saved = localStorage.getItem('cd_show_hostname_badge');
const isShown = saved !== null
? (saved === 'true')
: (App.systemStatus?.show_hostname_badge !== false && App.config?.ui?.show_hostname_badge !== false);
hostnameBadgeCheckbox.checked = isShown;
const wrapper = document.getElementById('wrapper-custom-hostname');
if (wrapper) wrapper.style.display = isShown ? 'flex' : 'none';
}
const cfg = getHostnameBadgeSettings();
const customHostnameInput = document.getElementById('setting-custom-hostname-label');
if (customHostnameInput) customHostnameInput.value = localStorage.getItem('cd_custom_hostname') || App.config?.ui?.hostname_badge || App.systemStatus?.custom_hostname || '';
const colorSelect = document.getElementById('setting-hostname-color');
if (colorSelect) colorSelect.value = cfg.color;
const styleSelect = document.getElementById('setting-hostname-style');
if (styleSelect) styleSelect.value = cfg.style;
const iconSelect = document.getElementById('setting-hostname-icon');
if (iconSelect) iconSelect.value = cfg.icon;
const sizeSelect = document.getElementById('setting-hostname-size');
if (sizeSelect) sizeSelect.value = cfg.size;
const customTextInput = document.getElementById('setting-hostname-custom-text');
if (customTextInput) customTextInput.value = cfg.customText;
const customBgInput = document.getElementById('setting-hostname-custom-bg');
if (customBgInput) customBgInput.value = cfg.customBg;
const customBorderInput = document.getElementById('setting-hostname-custom-border');
if (customBorderInput) customBorderInput.value = cfg.customBorder;
const customGlowInput = document.getElementById('setting-hostname-custom-glow');
if (customGlowInput) customGlowInput.value = cfg.customGlow;
const textPicker = document.getElementById('setting-hostname-custom-text-picker');
if (textPicker && cfg.customText?.startsWith('#')) textPicker.value = cfg.customText;
const borderPicker = document.getElementById('setting-hostname-custom-border-picker');
if (borderPicker && cfg.customBorder?.startsWith('#')) borderPicker.value = cfg.customBorder;
const glowPicker = document.getElementById('setting-hostname-custom-glow-picker');
if (glowPicker && cfg.customGlow?.startsWith('#')) glowPicker.value = cfg.customGlow;
updateHostnameSettingsPreview();
const customTitleInput = document.getElementById('setting-custom-window-title');
if (customTitleInput) {
customTitleInput.value = localStorage.getItem('cd_custom_app_title') || App.config?.ui?.window_title || App.systemStatus?.window_title || '';
}
const notedogFolderInput = document.getElementById('setting-notedog-folder');
if (notedogFolderInput) {
notedogFolderInput.value = localStorage.getItem('cd_notedog_folder') || notedogState.rootFolder || '~/Notes';
}
const dndActionSel = document.getElementById('setting-dnd-default-action');
if (dndActionSel) {
dndActionSel.value = App.dndDefaultAction || 'ask';
}
const dndPromptSel = document.getElementById('setting-dnd-prompt-mode');
if (dndPromptSel) {
dndPromptSel.value = App.dndPromptMode || 'ask';
}
const dndParanoidChk = document.getElementById('setting-dnd-paranoid-prompt');
if (dndParanoidChk) {
dndParanoidChk.checked = App.dndParanoidPrompt !== false;
}
const paneReorderCheckbox = document.getElementById('setting-pane-reorder');
if (paneReorderCheckbox) {
paneReorderCheckbox.checked = App.paneReorderEnabled !== false;
}
const activateOnHoverCheckbox = document.getElementById('setting-activate-on-hover');
if (activateOnHoverCheckbox) {
activateOnHoverCheckbox.checked = !!App.activateOnHover;
}
const hapticCheckbox = document.getElementById('setting-haptic-feedback');
if (hapticCheckbox) {
hapticCheckbox.checked = App.hapticFeedback !== false && localStorage.getItem('cd_haptic_feedback') !== 'false';
}
const maxPanesSel = document.getElementById('setting-phone-tablet-max-panes');
if (maxPanesSel) {
maxPanesSel.value = (App.phoneTabletMaxPanes || 2).toString();
}
const mobBarSel = document.getElementById('setting-mobile-bottom-bar-mode');
if (mobBarSel) {
mobBarSel.value = App.mobileBottomBarMode || localStorage.getItem('cd_mobile_bottom_bar_mode') || 'icons_text';
}
const syncScrollCb = document.getElementById('setting-sync-scroll');
if (syncScrollCb) {
syncScrollCb.checked = !!App.syncScroll;
}
const syncNavCb = document.getElementById('setting-sync-nav');
if (syncNavCb) {
syncNavCb.checked = !!App.syncNav;
}
// Populate External Programs & Desktop Handlers (Standalone only)
renderDesktopAppsTab();
updateStandaloneUI();
updateColumnCheckboxes();
renderIconSettingsTab();
showModal('settings-modal');
}
function toggleHapticFeedbackSetting(enabled) {
App.hapticFeedback = !!enabled;
localStorage.setItem('cd_haptic_feedback', enabled ? 'true' : 'false');
queueSaveUserPreferencesToServer();
if (enabled) {
triggerHaptic(35);
}
showToast(enabled ? 'Haptic touch feedback enabled' : 'Haptic touch feedback disabled', 'info');
}
function handleDndSettingChange() {
const actionSel = document.getElementById('setting-dnd-default-action');
const promptSel = document.getElementById('setting-dnd-prompt-mode');
const paranoidChk = document.getElementById('setting-dnd-paranoid-prompt');
if (actionSel) {
App.dndDefaultAction = actionSel.value;
localStorage.setItem('cd_dnd_default_action', actionSel.value);
}
if (promptSel) {
App.dndPromptMode = promptSel.value;
localStorage.setItem('cd_dnd_prompt_mode', promptSel.value);
}
if (paranoidChk) {
App.dndParanoidPrompt = paranoidChk.checked;
localStorage.setItem('cd_dnd_paranoid_prompt', paranoidChk.checked ? 'true' : 'false');
}
queueSaveUserPreferencesToServer();
}
function renderIconSettingsTab() {
const themeSel = document.getElementById('setting-icon-theme');
if (themeSel) themeSel.value = App.iconTheme || 'default';
const folderInput = document.getElementById('setting-global-folder-icon');
if (folderInput) folderInput.value = App.globalFolderIcon || '';
renderCustomIconsTable();
}
function changeGlobalIconTheme(theme) {
App.iconTheme = theme;
localStorage.setItem('cd_icon_theme', theme);
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast(`Global icon theme set to: ${theme}`, 'success');
}
function saveGlobalFolderIcon() {
const input = document.getElementById('setting-global-folder-icon');
const val = input ? input.value.trim() : '';
App.globalFolderIcon = val;
if (val) {
localStorage.setItem('cd_global_folder_icon', val);
showToast(`Global folder icon set to "${val}"`, 'success');
} else {
localStorage.removeItem('cd_global_folder_icon');
showToast('Global folder icon reset to default', 'info');
}
renderAllPanes();
queueSaveUserPreferencesToServer();
}
function resetGlobalFolderIcon() {
const input = document.getElementById('setting-global-folder-icon');
if (input) input.value = '';
App.globalFolderIcon = '';
localStorage.removeItem('cd_global_folder_icon');
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast('Global folder icon reset to default Nerd Font glyph', 'info');
}
function selectGlobalFolderPreset(preset) {
const input = document.getElementById('setting-global-folder-icon');
if (input) {
input.value = preset;
saveGlobalFolderIcon();
}
}
function renderCustomIconsTable() {
const tbody = document.getElementById('setting-custom-icons-table-body');
if (!tbody) return;
tbody.innerHTML = '';
const entries = Object.entries(App.customFileIcons || {});
if (entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--text-muted); padding: 12px;">No custom filetype icon rules configured yet.</td></tr>';
return;
}
entries.sort((a, b) => a[0].localeCompare(b[0])).forEach(([ext, iconVal]) => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid var(--border)';
tr.innerHTML = `
<td style="padding: 6px 10px; font-family: var(--font-mono); font-weight: 600; color: var(--accent);">${escapeHtml(ext)}</td>
<td style="padding: 6px 10px; font-family: var(--font-mono); font-size: 11px;">${escapeHtml(iconVal)}</td>
<td style="padding: 6px 10px; text-align: center;">${formatCustomIconToHtml(iconVal, 'sm', ext === 'folder')}</td>
<td style="padding: 6px 10px; text-align: right;">
<button class="btn btn-icon btn-xs btn-danger" onclick="removeCustomFileIconRule('${escapeHtml(ext)}')"><i data-lucide="trash-2" style="width: 12px; height: 12px;"></i></button>
</td>
`;
tbody.appendChild(tr);
});
if (window.lucide) lucide.createIcons({ root: tbody });
}
function addCustomFileIconRule() {
const extInput = document.getElementById('setting-new-icon-ext');
const valInput = document.getElementById('setting-new-icon-val');
if (!extInput || !valInput) return;
const ext = extInput.value.trim().toLowerCase().replace(/^\./, '');
const val = valInput.value.trim();
if (!ext || !val) {
showToast('Please specify both extension and icon/glyph', 'warning');
return;
}
if (!App.customFileIcons) App.customFileIcons = {};
App.customFileIcons[ext] = val;
localStorage.setItem('cd_custom_file_icons', JSON.stringify(App.customFileIcons));
extInput.value = '';
valInput.value = '';
renderCustomIconsTable();
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast(`Custom icon rule saved for .${ext}`, 'success');
}
function removeCustomFileIconRule(ext) {
if (App.customFileIcons && App.customFileIcons[ext]) {
delete App.customFileIcons[ext];
localStorage.setItem('cd_custom_file_icons', JSON.stringify(App.customFileIcons));
renderCustomIconsTable();
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast(`Removed rule for .${ext}`, 'info');
}
}
function loadNerdFontPresets() {
App.customFileIcons = { ...NERDFONT_PRESET };
localStorage.setItem('cd_custom_file_icons', JSON.stringify(App.customFileIcons));
renderCustomIconsTable();
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast('Loaded complete Nerd Font developer preset suite!', 'success');
}
function loadEmojiPresets() {
App.customFileIcons = { ...EMOJI_PRESET };
localStorage.setItem('cd_custom_file_icons', JSON.stringify(App.customFileIcons));
renderCustomIconsTable();
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast('Loaded complete Emoji Suite icon preset!', 'success');
}
function clearAllCustomFileIcons() {
App.customFileIcons = {};
localStorage.removeItem('cd_custom_file_icons');
renderCustomIconsTable();
renderAllPanes();
queueSaveUserPreferencesToServer();
showToast('Cleared all custom filetype icon rules', 'info');
}
function toggleMinimizeToTray(enabled) {
localStorage.setItem('cd_minimize_tray', enabled);
showToast(enabled ? 'Minimize to System Tray enabled' : 'Minimize to System Tray disabled', 'info');
}
function toggleStartMinimized(enabled) {
localStorage.setItem('cd_start_minimized', enabled);
showToast(enabled ? 'Start minimized in tray enabled' : 'Start minimized disabled', 'info');
}
function triggerView() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || pane.entries[pane.cursorIndex];
if (!item) return;
if (isVaultFile(item.name)) {
handleVaultOpen(item.path, App.activePaneIndex);
return;
}
if (item.is_dir || item.is_archive) {
loadPaneDirectory(App.activePaneIndex, item.path);
return;
}
// Standalone desktop mode: override F3 with external viewer if enabled
if (isStandaloneMode()) {
const saved = localStorage.getItem('cd_use_ext_viewer_f3');
const useExtViewer = saved !== null ? (saved === 'true') : (App.config?.desktop?.use_external_viewer_f3 === true);
if (useExtViewer) {
const extCmd = localStorage.getItem('cd_external_viewer') || App.config?.desktop?.external_viewer || '';
executeOpenWith(item.path, extCmd.trim() ? extCmd : null);
return;
}
}
if (isCadOr3dExtension(item.name)) {
openCadStudio(item.path, App.activePaneIndex);
return;
}
if (isDocumentExtension(item.name)) {
openDocumentViewer(item.path, App.activePaneIndex);
return;
}
if (isAudioExtension(item.name)) {
openSoundDog(item.path, App.activePaneIndex);
return;
}
if (isVideoExtension(item.name)) {
openMediaPlayer(item.path, 'video', App.activePaneIndex);
return;
}
if (isComicBookExtension(item.name)) {
openBookReader(item.path);
return;
}
if (isImageExtension(item.name)) {
openImageViewer(item.path, App.activePaneIndex);
return;
}
openDocumentViewer(item.path, App.activePaneIndex);
}
function triggerEditor() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || pane.entries[pane.cursorIndex];
// Standalone desktop mode: override F4 with external editor if enabled
if (item && !item.is_dir && isStandaloneMode()) {
const saved = localStorage.getItem('cd_use_ext_editor_f4');
const useExtEditor = saved !== null ? (saved === 'true') : (App.config?.desktop?.use_external_editor_f4 === true);
if (useExtEditor) {
const extCmd = localStorage.getItem('cd_external_editor') || App.config?.desktop?.external_editor || '';
executeOpenWith(item.path, extCmd.trim() ? extCmd : null);
return;
}
}
if (item) {
if (item.is_dir) {
checkAndPromptConfdDirectory(item.path);
} else {
openEditorWithFile(item.path, App.activePaneIndex);
}
} else {
if (editorTabs.length === 0) createNewEditorTab();
openFloatingEditor();
}
}
function triggerMkdir() {
const targetPaneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
const pane = App.panes[targetPaneIdx];
// Reset context flags now that pane index has been captured
App.contextItem = null;
App.contextPaneIndex = null;
if (!pane) {
showToast('No active pane available', 'error');
return;
}
const input = document.getElementById('mkdir-input');
if (!input) {
console.error('mkdir-input not found');
return;
}
input.value = '';
showModal('mkdir-modal');
// Autofocus input
setTimeout(() => {
input.focus();
input.select();
}, 40);
const executeMkdir = async () => {
const name = input.value.trim();
if (!name) {
closeModal('mkdir-modal');
return;
}
closeModal('mkdir-modal');
if (pane.path && pane.path.startsWith('client://')) {
try {
await createClientLocalDirectory(pane.path, name);
showToast(`Created folder "${name}"`, 'success');
refreshAllPanes();
} catch (err) {
showToast('Failed to create folder: ' + err.message, 'error');
}
return;
}
const newDir = joinItemPath(pane.path, name);
const authDir = resolveAuthUri(newDir);
const endpoint = getPaneEndpoint(App.activePaneIndex);
const headers = getPaneAuthHeaders(App.activePaneIndex, { 'Content-Type': 'application/json' });
try {
const resp = await fetch(`${endpoint}/api/fs/mkdir`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ path: authDir })
});
if (resp.ok) {
showToast(`Created folder "${name}"`, 'success');
refreshAllPanes();
} else {
showToast('Failed to create folder: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Failed to create folder: ' + err.message, 'error');
}
};
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeMkdir();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal('mkdir-modal');
}
};
const confirmBtn = document.getElementById('btn-confirm-mkdir');
if (confirmBtn) confirmBtn.onclick = executeMkdir;
}
function triggerRename(forceModal = false) {
const targetPaneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
const pane = App.panes[targetPaneIdx];
if (!pane) {
showToast('No active pane available', 'error');
return;
}
// Resolve target item:
// 1. App.contextItem (if triggered via right-click)
// 2. Single selected item in pane.selected
// 3. Item currently under cursor (pane.cursorIndex)
let item = App.contextItem;
if (!item && pane.selected && pane.selected.size === 1 && pane.entries) {
const selectedPath = Array.from(pane.selected)[0];
item = pane.entries.find(e => e.path === selectedPath);
}
if (!item && pane.entries && pane.cursorIndex >= 0 && pane.cursorIndex < pane.entries.length) {
item = pane.entries[pane.cursorIndex];
}
// Reset context flags now that item has been captured
App.contextItem = null;
App.contextPaneIndex = null;
if (!item || item.name === '..' || item.name === '.') {
showToast('Please select a file or folder to rename', 'info');
return;
}
const isTouch = window.innerWidth <= 768 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
if (!forceModal && !isTouch) {
const started = startInPlaceRename(targetPaneIdx, item);
if (started) return;
}
openRenameModal(targetPaneIdx, item);
}
function startInPlaceRename(targetPaneIdx, item) {
const pane = App.panes[targetPaneIdx];
if (!pane) return false;
const paneEl = document.getElementById(`pane-${targetPaneIdx}`);
if (!paneEl) return false;
// Search for the element in table, grid, or compact view by dataset.path matching item.path
const allRows = paneEl.querySelectorAll('tr.file-row, .grid-gallery-card, .compact-list-item');
let targetRow = null;
for (const el of allRows) {
if (el.dataset.path === item.path) {
targetRow = el;
break;
}
}
let nameEl = null;
if (targetRow) {
if (targetRow.matches('tr.file-row')) {
nameEl = targetRow.querySelector('.file-name-text');
} else if (targetRow.matches('.grid-gallery-card')) {
nameEl = targetRow.querySelector('.grid-gallery-title') || targetRow.querySelector('div[title]');
} else if (targetRow.matches('.compact-list-item')) {
nameEl = targetRow.querySelector('span:last-child');
}
}
if (!nameEl) return false;
// Prevent duplicate input if already renaming
if (nameEl.querySelector('.inplace-rename-input')) return true;
const originalName = item.name;
const originalHtml = nameEl.innerHTML;
const input = document.createElement('input');
input.type = 'text';
input.className = 'inplace-rename-input';
input.value = originalName;
nameEl.innerHTML = '';
nameEl.appendChild(input);
let committed = false;
const cancel = () => {
if (committed) return;
committed = true;
nameEl.innerHTML = originalHtml;
};
const commit = async () => {
if (committed) return;
const newName = input.value.trim();
if (!newName || newName === originalName) {
cancel();
return;
}
committed = true;
const parentDir = getParentDirectory(item.path) || pane.path;
const toPath = joinItemPath(parentDir, newName);
if (item.path && item.path.startsWith('client://')) {
try {
await renameClientLocalItem(item.path, newName);
showToast(`Renamed to "${newName}"`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected && p.selected.has(item.path)) {
p.selected.delete(item.path);
p.selected.add(toPath);
}
});
}
refreshAllPanes();
} catch (e) {
showToast(`Rename error: ${e.message}`, 'error');
cancel();
refreshAllPanes();
}
return;
}
const fromAuth = resolveAuthUri(item.path);
const toAuth = resolveAuthUri(toPath);
const endpoint = getPaneEndpoint(targetPaneIdx);
const headers = getPaneAuthHeaders(targetPaneIdx, { 'Content-Type': 'application/json' });
try {
const resp = await fetch(`${endpoint}/api/fs/rename`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
from: fromAuth,
to: toAuth,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (resp.ok) {
showToast(`Renamed to "${newName}"`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected && p.selected.has(item.path)) {
p.selected.delete(item.path);
p.selected.add(toPath);
}
});
}
refreshAllPanes();
} else {
const err = await resp.text();
showToast(`Rename failed: ${sanitizeCredentials(err)}`, 'error');
cancel();
refreshAllPanes();
}
} catch (e) {
showToast(`Rename error: ${e.message}`, 'error');
cancel();
refreshAllPanes();
}
};
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
});
input.addEventListener('blur', () => {
commit();
});
input.addEventListener('click', (e) => e.stopPropagation());
input.addEventListener('dblclick', (e) => e.stopPropagation());
input.addEventListener('contextmenu', (e) => e.stopPropagation());
setTimeout(() => {
input.focus();
if (item.is_dir) {
input.select();
} else {
const lastDot = originalName.lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
}
}, 20);
return true;
}
function openRenameModal(targetPaneIdx, item) {
const pane = App.panes[targetPaneIdx];
if (!pane || !item) return;
const input = document.getElementById('rename-input');
if (!input) return;
input.value = item.name;
showModal('rename-modal');
// Focus and pre-select filename (excluding file extension)
setTimeout(() => {
input.focus();
if (item.is_dir) {
input.select();
} else {
const lastDot = item.name.lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
}
}, 40);
const executeRename = async () => {
const newName = input.value.trim();
if (!newName || newName === item.name) {
closeModal('rename-modal');
return;
}
const parentDir = getParentDirectory(item.path) || pane.path;
const toPath = joinItemPath(parentDir, newName);
closeModal('rename-modal');
if (item.path && item.path.startsWith('client://')) {
try {
await renameClientLocalItem(item.path, newName);
showToast(`Renamed to "${newName}"`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected && p.selected.has(item.path)) {
p.selected.delete(item.path);
p.selected.add(toPath);
}
});
}
refreshAllPanes();
} catch (err) {
showToast('Rename failed: ' + err.message, 'error');
}
return;
}
const fromAuth = resolveAuthUri(item.path);
const toAuth = resolveAuthUri(toPath);
const endpoint = getPaneEndpoint(targetPaneIdx);
const headers = getPaneAuthHeaders(targetPaneIdx, { 'Content-Type': 'application/json' });
try {
const resp = await fetch(`${endpoint}/api/fs/rename`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
from: fromAuth,
to: toAuth,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (resp.ok) {
showToast(`Renamed to "${newName}"`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected && p.selected.has(item.path)) {
p.selected.delete(item.path);
p.selected.add(toPath);
}
});
}
refreshAllPanes();
} else {
showToast('Rename failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Rename failed: ' + err.message, 'error');
}
};
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeRename();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal('rename-modal');
}
};
const confirmBtn = document.getElementById('btn-confirm-rename');
if (confirmBtn) confirmBtn.onclick = executeRename;
}
async function triggerDelete() {
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
const targetPaneIdx = (App.contextPaneIndex !== null && App.contextPaneIndex !== undefined) ? App.contextPaneIndex : App.activePaneIndex;
const useTrash = App.trashEnabled !== false;
const customTrash = App.customTrashDir || null;
const trashName = getTrashDisplayName(false);
const itemNames = paths.map(p => sanitizeCredentials(p).split('/').pop() || p);
const confirmed = await showConfirmDialog({
title: useTrash ? `Move to ${trashName} Confirmation` : 'Permanent Deletion Confirmation',
subtitle: useTrash ? `Move ${paths.length} item(s) to ${trashName}` : `Permanently Delete ${paths.length} item(s)`,
message: useTrash
? `Move ${paths.length === 1 ? `'${itemNames[0]}'` : `${paths.length} selected items`} to ${trashName}?`
: `Permanently delete ${paths.length === 1 ? `'${itemNames[0]}'` : `${paths.length} selected items`}? This action cannot be undone!`,
items: itemNames,
icon: 'trash-2',
type: 'danger',
confirmText: useTrash ? `Move to ${trashName} (F8)` : 'Permanently Delete (F8)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
const clientPaths = paths.filter(p => typeof p === 'string' && p.startsWith('client://'));
const serverPaths = paths.filter(p => !p.startsWith('client://'));
if (clientPaths.length > 0) {
for (const cp of clientPaths) {
await deleteClientLocalItem(cp);
}
}
if (serverPaths.length > 0) {
const endpoint = getPaneEndpoint(targetPaneIdx);
const headers = getPaneAuthHeaders(targetPaneIdx, { 'Content-Type': 'application/json' });
const authPaths = serverPaths.map(p => resolveAuthUri(p));
const resp = await fetch(`${endpoint}/api/fs/delete`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
paths: authPaths,
use_trash: useTrash,
custom_trash_dir: customTrash,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (!resp.ok) {
showToast(`Delete failed: ${sanitizeCredentials(await resp.text())}`, 'error');
refreshAllPanes();
return;
}
}
showToast(useTrash ? `Moved ${paths.length} item(s) to ${trashName}` : `Permanently deleted ${paths.length} item(s)`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected) {
paths.forEach(delPath => p.selected.delete(delPath));
}
});
}
refreshAllPanes();
App.contextItem = null;
} catch (err) {
showToast(`Delete error: ${sanitizeCredentials(String(err))}`, 'error');
}
}
}
let pendingDeltaTransfer = {
sources: [],
destination: '',
targetIdx: 0
};
function getDefaultCopyAction() {
return localStorage.getItem('brum_default_copy_action') || 'deltacopy';
}
function updateDefaultCopyAction(val) {
const cleanVal = (val === 'standard') ? 'standard' : 'deltacopy';
localStorage.setItem('brum_default_copy_action', cleanVal);
if (typeof queueSaveUserPreferencesToServer === 'function') queueSaveUserPreferencesToServer();
showToast(`Default F5 action set to ${cleanVal === 'standard' ? 'Standard Copy' : 'DeltaCopy'}`, 'info');
}
function triggerCopy() {
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const sourcePane = App.panes[App.activePaneIndex];
const targetPane = App.panes[targetIdx];
const paths = sourcePane.selected.size > 0 ? Array.from(sourcePane.selected) : (sourcePane.entries[sourcePane.cursorIndex] ? [sourcePane.entries[sourcePane.cursorIndex].path] : []);
if (paths.length === 0) return;
pendingDeltaTransfer = {
sources: paths,
destination: targetPane.path,
targetIdx: targetIdx,
sourceIdx: App.activePaneIndex
};
const summary = document.getElementById('deltacopy-source-summary');
if (summary) summary.innerHTML = paths.map(p => `<div style="display: flex; align-items: center; gap: 4px;">${formatCustomIconToHtml('', 'sm', true, 'var(--accent)')} ${escapeHtml(p)}</div>`).join('');
const destInput = document.getElementById('deltacopy-dest-input');
if (destInput) {
destInput.value = targetPane.path;
destInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (getDefaultCopyAction() === 'standard') {
executeStandardCopy();
} else {
executeDeltaCopy();
}
}
};
}
// Highlight default action button based on user preference
const defaultAction = getDefaultCopyAction();
const btnStandard = document.getElementById('btn-copy-standard');
const btnDelta = document.getElementById('btn-copy-delta');
if (btnStandard && btnDelta) {
if (defaultAction === 'standard') {
btnStandard.className = 'btn btn-accent modal-action-btn';
btnDelta.className = 'btn modal-action-btn';
} else {
btnStandard.className = 'btn modal-action-btn';
btnDelta.className = 'btn btn-accent modal-action-btn';
}
}
showModal('deltacopy-modal');
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons();
}
if (destInput) {
setTimeout(() => {
destInput.focus();
destInput.select();
}, 40);
}
}
async function executeDeltaCopy() {
const dest = document.getElementById('deltacopy-dest-input').value;
const skipIdentical = document.getElementById('delta-skip-identical').checked;
const verifyCrc = document.getElementById('delta-verify-crc32').checked;
const autoRetry = document.getElementById('delta-auto-retry').checked;
const preserveMeta = document.getElementById('delta-preserve-meta').checked;
closeModal('deltacopy-modal');
const srcIdx = (pendingDeltaTransfer && typeof pendingDeltaTransfer.sourceIdx === 'number') ? pendingDeltaTransfer.sourceIdx : App.activePaneIndex;
const destIdx = (pendingDeltaTransfer && typeof pendingDeltaTransfer.targetIdx === 'number') ? pendingDeltaTransfer.targetIdx : (srcIdx + 1) % getVisiblePaneCount();
const srcNode = getPaneNode(srcIdx);
const destNode = getPaneNode(destIdx);
if (pendingDeltaTransfer.sources.some(s => typeof s === 'string' && s.startsWith('client://')) || (typeof dest === 'string' && dest.startsWith('client://'))) {
return executeClientLocalTransfer('copy', pendingDeltaTransfer.sources, dest, destIdx, srcIdx);
}
if (srcNode.id !== destNode.id) {
return executeCrossNodeTransfer('copy', pendingDeltaTransfer.sources, dest, destIdx, srcIdx);
}
const payload = {
sources: pendingDeltaTransfer.sources,
destination: dest,
options: {
overwrite_mode: skipIdentical ? 'delta_mtime_size' : 'always',
verify_checksum: verifyCrc,
verify_algo: 'crc32',
retry_count: autoRetry ? 3 : 1,
retry_delay_ms: 500,
resume_partial: true,
preserve_timestamps: preserveMeta,
preserve_permissions: preserveMeta,
delete_orphans: false
}
};
const endpoint = getPaneEndpoint(srcIdx);
const headers = getPaneAuthHeaders(srcIdx, { 'Content-Type': 'application/json' });
const resp = await fetch(`${endpoint}/api/fs/deltacopy`, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
});
if (resp.ok) {
pollTasks();
openFloatingTaskManager();
setTimeout(() => refreshAllPanes(), 1500);
} else {
showToast('Failed to start DeltaCopy: ' + await resp.text(), 'error');
}
}
async function executeStandardCopy() {
const dest = document.getElementById('deltacopy-dest-input').value;
closeModal('deltacopy-modal');
const targetIdx = pendingDeltaTransfer ? pendingDeltaTransfer.targetIdx : undefined;
const sourceIdx = pendingDeltaTransfer ? pendingDeltaTransfer.sourceIdx : undefined;
executeTransfer('copy', pendingDeltaTransfer.sources, dest, targetIdx, sourceIdx);
}
function triggerMove() {
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const sourcePane = App.panes[App.activePaneIndex];
const targetPane = App.panes[targetIdx];
const paths = sourcePane.selected.size > 0 ? Array.from(sourcePane.selected) : (sourcePane.entries[sourcePane.cursorIndex] ? [sourcePane.entries[sourcePane.cursorIndex].path] : []);
if (paths.length === 0) return;
if (App.paranoidMode) {
showParanoidConfirm('move', paths, targetPane.path, () => executeTransfer('move', paths, targetPane.path, targetIdx, App.activePaneIndex), App.activePaneIndex);
} else {
executeTransfer('move', paths, targetPane.path, targetIdx, App.activePaneIndex);
}
}
async function triggerChecksum() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || pane.entries[pane.cursorIndex];
if (!item) return;
const resp = await fetch('/api/fs/checksum', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ path: item.path, algorithm: 'sha256' })
});
if (resp.ok) {
const data = await resp.json();
await showAlertDialog({
title: 'Cryptographic Checksum',
subtitle: 'SHA-256 Verification',
message: `<strong>File:</strong> <code>${escapeHtml(data.path)}</code><br><strong>Size:</strong> ${formatBytes(data.file_size)}<br><br><strong>SHA-256 Hash:</strong><br><input type="text" class="input" readonly value="${data.hash}" style="width: 100%; font-family: var(--font-mono); margin-top: 6px; font-size: 11px; box-sizing: border-box;" onclick="this.select(); navigator.clipboard.writeText('${data.hash}'); showToast('Hash copied to clipboard!', 'success');">`,
icon: 'shield-check',
type: 'info',
okText: 'Done',
});
} else {
showToast('Failed to calculate checksum: ' + await resp.text(), 'error');
}
}
// ---------------- 1-CLICK SPLIT ACTION TRANSFER HELPERS ----------------
function getPane1ClickTransferAction() {
return localStorage.getItem('brum_pane_1click_transfer') || 'copy';
}
function get1ClickActionLabel(action) {
const map = {
copy: 'Copy to Other Pane (F5)',
move: 'Move to Other Pane (F6)',
clone_path: 'Clone Path to Other Pane',
upload_file: 'Upload Files...',
upload_folder: 'Upload Folder...',
download_zip: 'Download Folder (.zip)',
};
return map[action] || map.copy;
}
function getPane1ClickTransferIcon(action = getPane1ClickTransferAction()) {
const map = {
copy: 'arrow-right-left',
move: 'arrow-right',
clone_path: 'copy',
upload_file: 'upload',
upload_folder: 'folder-up',
download_zip: 'archive',
};
return map[action] || 'arrow-right-left';
}
function getPane1ClickTransferTooltip(action = getPane1ClickTransferAction()) {
const map = {
copy: 'Transfer / Copy to Other Pane (F5) [Right-click for options]',
move: 'Transfer / Move to Other Pane (F6) [Right-click for options]',
clone_path: 'Clone Current Path to Other Pane [Right-click for options]',
upload_file: 'Upload Files from Device [Right-click for options]',
upload_folder: 'Upload Folder from Device [Right-click for options]',
download_zip: 'Download Current Folder as .zip [Right-click for options]',
};
return map[action] || 'Transfer / Copy to Other Pane (F5) [Right-click for options]';
}
function setPane1ClickTransferAction(action) {
const allowed = ['copy', 'move', 'clone_path', 'upload_file', 'upload_folder', 'download_zip'];
const cleanAction = allowed.includes(action) ? action : 'copy';
localStorage.setItem('brum_pane_1click_transfer', cleanAction);
if (typeof queueSaveUserPreferencesToServer === 'function') queueSaveUserPreferencesToServer();
// Dynamically update toolbar buttons in active DOM without full re-render
const paneCount = (App.panes && App.panes.length) ? App.panes.length : 2;
for (let i = 0; i < paneCount; i++) {
const mainBtn = document.getElementById(`btn-transfer-main-${i}`);
if (mainBtn) {
mainBtn.title = getPane1ClickTransferTooltip();
mainBtn.innerHTML = `<i data-lucide="${getPane1ClickTransferIcon()}"></i>`;
}
}
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons();
}
showToast(`1-Click Transfer action: ${get1ClickActionLabel(cleanAction)}`, 'info');
}
function cloneDirectoryToOppositePane(paneIndex) {
const srcIdx = paneIndex !== undefined ? paneIndex : App.activePaneIndex;
const visibleCount = typeof getVisiblePaneCount === 'function' ? getVisiblePaneCount() : (App.panes ? App.panes.length : 2);
if (visibleCount < 2) {
showToast('Need at least 2 visible panes to clone path', 'warning');
return;
}
const targetIdx = (srcIdx + 1) % visibleCount;
const srcPane = App.panes[srcIdx];
if (!srcPane || !srcPane.path) return;
loadDirectory(targetIdx, srcPane.path);
showToast(`Cloned path to Pane ${targetIdx + 1}`, 'info');
}
function executePane1ClickTransfer(paneIndex) {
if (paneIndex !== undefined && typeof setActivePane === 'function') {
setActivePane(paneIndex);
}
const action = getPane1ClickTransferAction();
switch (action) {
case 'move':
triggerMove();
break;
case 'clone_path':
cloneDirectoryToOppositePane(paneIndex);
break;
case 'upload_file':
triggerDeviceUpload(paneIndex);
break;
case 'upload_folder':
triggerDeviceFolderUpload(paneIndex);
break;
case 'download_zip':
triggerDownloadCurrentDirectory(paneIndex);
break;
case 'copy':
default:
triggerCopy();
break;
}
}
// ---------------- DEVICE UPLOADS & TRANSFERS (PHONE / TABLET / DESKTOP) ----------------
let targetUploadPaneIndex = null;
function openPaneTransferMenu(e, paneIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const existing = document.getElementById('pane-transfer-popup');
const wasOpenForThisPane = existing && existing.dataset.paneIndex === String(paneIndex);
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
if (wasOpenForThisPane) return;
const curAction = getPane1ClickTransferAction();
const popup = document.createElement('div');
popup.id = 'pane-transfer-popup';
popup.dataset.paneIndex = String(paneIndex);
popup.className = 'pane-transfer-dropdown active';
popup.innerHTML = `
<div style="padding: 8px 12px; font-weight: 700; font-size: 11px; color: var(--accent); background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<span style="display: flex; align-items: center; gap: 6px;">
<i data-lucide="arrow-right-left" style="width: 14px; height: 14px;"></i>
Transfer & Ingest Hub (Pane ${paneIndex + 1})
</span>
<span style="font-size: 11px; color: var(--text-dim); cursor: pointer;" onclick="document.getElementById('pane-transfer-popup')?.remove();">✕</span>
</div>
<div class="pane-dropdown-body" style="padding: 4px 0;">
<!-- Dual-Pane Transfers -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Dual-Pane Transfers</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); setActivePane(${paneIndex}); triggerCopy();">
<i data-lucide="arrow-right-left" style="color: var(--accent);"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Copy to Other Pane (F5)</span>
${curAction === 'copy' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">DeltaCopy or Standard to target pane</div>
</div>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); setActivePane(${paneIndex}); triggerMove();">
<i data-lucide="arrow-right" style="color: var(--accent);"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Move to Other Pane (F6)</span>
${curAction === 'move' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Move selected items to target pane</div>
</div>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); cloneDirectoryToOppositePane(${paneIndex});">
<i data-lucide="copy" style="color: var(--accent);"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Clone Path to Other Pane</span>
${curAction === 'clone_path' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Open this folder in opposite pane</div>
</div>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
<!-- Device Ingest & Egress -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Device Ingest & Egress</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); triggerDeviceUpload(${paneIndex});">
<i data-lucide="file-up" style="color: var(--accent);"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Upload Files...</span>
${curAction === 'upload_file' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Select files from local device</div>
</div>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); triggerDeviceFolderUpload(${paneIndex});">
<i data-lucide="folder-up" style="color: var(--accent);"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Upload Folder...</span>
${curAction === 'upload_folder' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Upload directory hierarchy recursively</div>
</div>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); triggerDownloadCurrentDirectory(${paneIndex});">
<i data-lucide="archive"></i>
<div style="flex: 1;">
<div style="font-weight: 600; display: flex; align-items: center; justify-content: space-between;">
<span>Download Folder (.zip)</span>
${curAction === 'download_zip' ? '<span style="font-size: 9px; padding: 1px 4px; border-radius: 3px; background: rgba(245,158,11,0.18); color: var(--accent); font-weight: 700;">1-CLICK</span>' : ''}
</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Zip and download pane contents</div>
</div>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
<!-- Sharing & Links -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Sharing & Links</div>
<div class="dropdown-item" onclick="document.getElementById('pane-transfer-popup')?.remove(); triggerShareDirectory(${paneIndex});">
<i data-lucide="share-2"></i>
<div>
<div style="font-weight: 600;">Share Folder / Dropbox...</div>
<div style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">Generate shareable link or upload portal</div>
</div>
</div>
</div>
<!-- 1-Click Action Config Footer -->
<div style="padding: 6px 12px 6px; background: var(--bg-dark); border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<span style="font-size: 10px; color: var(--text-muted); font-weight: 600; white-space: nowrap;">1-Click Action:</span>
<select class="select" style="font-size: 11px; height: 22px; padding: 0 4px; border-radius: 4px; background: var(--bg-panel); color: var(--text-main); border: 1px solid var(--border); flex: 1; max-width: 170px;" onchange="setPane1ClickTransferAction(this.value); document.getElementById('pane-transfer-popup')?.remove();">
<option value="copy" ${curAction === 'copy' ? 'selected' : ''}>Copy (F5)</option>
<option value="move" ${curAction === 'move' ? 'selected' : ''}>Move (F6)</option>
<option value="clone_path" ${curAction === 'clone_path' ? 'selected' : ''}>Clone Path</option>
<option value="upload_file" ${curAction === 'upload_file' ? 'selected' : ''}>Upload Files</option>
<option value="upload_folder" ${curAction === 'upload_folder' ? 'selected' : ''}>Upload Folder</option>
<option value="download_zip" ${curAction === 'download_zip' ? 'selected' : ''}>Download Zip</option>
</select>
</div>
`;
const btn = document.getElementById(`btn-transfer-menu-${paneIndex}`) || document.getElementById(`pane-transfer-wrap-${paneIndex}`) || document.getElementById(`btn-upload-${paneIndex}`);
if (btn) {
const rect = btn.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = `${rect.bottom + 4}px`;
popup.style.right = `${Math.max(10, window.innerWidth - rect.right)}px`;
popup.style.zIndex = '999999';
} else {
popup.style.position = 'fixed';
popup.style.top = '60px';
popup.style.right = '10px';
popup.style.zIndex = '999999';
}
document.body.appendChild(popup);
if (window.lucide) lucide.createIcons();
const closeHandler = (ev) => {
if (!popup.contains(ev.target) && !btn?.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closeHandler);
document.removeEventListener('touchstart', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
document.addEventListener('touchstart', closeHandler);
}, 10);
}
// Backward-compatibility alias
function openPaneUploadMenu(e, paneIndex) {
return openPaneTransferMenu(e, paneIndex);
}
function triggerDeviceUpload(paneIndex) {
targetUploadPaneIndex = paneIndex !== undefined ? paneIndex : App.activePaneIndex;
const input = document.getElementById('direct-file-uploader');
if (input) {
input.value = '';
input.click();
}
}
function triggerDeviceFolderUpload(paneIndex) {
targetUploadPaneIndex = paneIndex !== undefined ? paneIndex : App.activePaneIndex;
const input = document.getElementById('direct-folder-uploader');
if (input) {
input.value = '';
input.click();
}
}
async function handleDirectFileUpload(files) {
if (!files || files.length === 0) return;
const paneIdx = targetUploadPaneIndex !== null ? targetUploadPaneIndex : App.activePaneIndex;
const pane = App.panes[paneIdx];
if (!pane) return;
const destPath = pane.path;
const fileArr = Array.from(files);
const collisions = detectCollisions(fileArr, pane.entries || [], destPath);
if (collisions.length > 0) {
pendingConflictBatch = {
action: 'upload',
files: fileArr,
destination: destPath,
refreshTargetPaneIdx: paneIdx,
sourcePaneIdx: paneIdx,
collisions,
currentIndex: 0,
decisions: {},
uploadType: 'direct'
};
renderCurrentConflictModal();
showModal('conflict-resolution-modal');
return;
}
executeUploadWithDecisions({
files: fileArr,
destination: destPath,
refreshTargetPaneIdx: paneIdx,
decisions: {}
});
}
function triggerDownloadCurrentDirectory(paneIndex) {
const pane = App.panes[paneIndex !== undefined ? paneIndex : App.activePaneIndex];
if (!pane) return;
const url = `/api/fs/download?path=${encodeURIComponent(pane.path)}`;
window.location.href = url;
}
function triggerShareDirectory(paneIndex) {
const pane = App.panes[paneIndex !== undefined ? paneIndex : App.activePaneIndex];
if (!pane) return;
const dirName = pane.path.split('/').filter(Boolean).pop() || 'root';
App.contextItem = {
name: dirName,
path: pane.path,
is_dir: true,
};
App.contextPaneIndex = paneIndex !== undefined ? paneIndex : App.activePaneIndex;
triggerShare();
}
// ---------------- ADVANCED SHARING & SHOWCASE CENTER ----------------
let pendingShareItem = null;
let allLoadedShares = [];
function handleShareModeChange() {
const mode = document.querySelector('input[name="share-mode"]:checked')?.value || 'full';
const watermarkToggle = document.getElementById('share-watermark-toggle');
const watermarkBox = document.getElementById('share-watermark-text-box');
if (mode === 'showcase') {
if (watermarkToggle && !watermarkToggle.checked) {
watermarkToggle.checked = true;
if (watermarkBox) watermarkBox.style.display = 'block';
}
}
}
function triggerShare(itemOrPath = null) {
const pane = App.panes[App.activePaneIndex];
let item = null;
if (itemOrPath) {
if (typeof itemOrPath === 'string') {
const name = itemOrPath.split('/').filter(Boolean).pop() || 'item';
item = { name: name, path: itemOrPath, is_dir: false };
} else {
item = itemOrPath;
}
} else {
item = App.contextItem || (pane && pane.entries && pane.entries[pane.cursorIndex]);
}
if (!item) {
showToast('Please select a file or folder to share', 'info');
return;
}
pendingShareItem = item;
document.getElementById('share-item-name').textContent = item.name;
document.getElementById('share-item-path').textContent = item.path;
document.getElementById('share-item-icon').textContent = item.is_dir ? '📁' : '📄';
document.getElementById('share-custom-title').value = item.name;
document.getElementById('share-password-input').value = '';
document.getElementById('share-max-downloads').value = '0';
document.getElementById('share-expiry-select').value = '24';
document.getElementById('share-require-email').checked = false;
document.getElementById('share-emails-box').style.display = 'none';
document.getElementById('share-allowed-emails').value = '';
document.getElementById('share-watermark-toggle').checked = false;
document.getElementById('share-watermark-text-box').style.display = 'none';
document.getElementById('share-watermark-text').value = 'CONFIDENTIAL • {email} • {date}';
const modeFullRadio = document.getElementById('share-mode-full');
if (modeFullRadio) modeFullRadio.checked = true;
const dropboxCard = document.getElementById('mode-card-dropbox');
if (dropboxCard) {
dropboxCard.style.display = item.is_dir ? 'flex' : 'none';
}
document.getElementById('share-result-box').style.display = 'none';
showModal('share-create-modal');
}
async function executeCreateShare() {
if (!pendingShareItem) return;
const mode = document.querySelector('input[name="share-mode"]:checked')?.value || 'full';
const customTitle = document.getElementById('share-custom-title').value.trim() || pendingShareItem.name;
const expiryHours = parseInt(document.getElementById('share-expiry-select').value, 10);
const password = document.getElementById('share-password-input').value.trim();
const maxDownloads = parseInt(document.getElementById('share-max-downloads').value, 10) || 0;
const allowView = true;
const allowDownload = mode !== 'showcase';
const allowUpload = mode === 'dropbox';
const requireEmail = document.getElementById('share-require-email').checked;
const allowedEmails = requireEmail ? document.getElementById('share-allowed-emails').value.trim() : null;
const watermarkEnabled = document.getElementById('share-watermark-toggle').checked;
const watermarkText = watermarkEnabled ? document.getElementById('share-watermark-text').value.trim() : null;
try {
const resp = await fetch('/api/shares', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
path: pendingShareItem.path,
name: customTitle,
is_dir: pendingShareItem.is_dir,
allow_upload: allowUpload,
allow_view: allowView,
allow_download: allowDownload,
password: password ? password : null,
expires_in_hours: expiryHours > 0 ? expiryHours : null,
max_downloads: maxDownloads,
allowed_emails: allowedEmails || null,
require_email: requireEmail,
watermark_enabled: watermarkEnabled,
watermark_text: watermarkText || null
})
});
if (!resp.ok) {
showToast('Failed to create share: ' + await resp.text(), 'error');
return;
}
const share = await resp.json();
const fullUrl = `${window.location.origin}/share/${share.token}`;
document.getElementById('share-result-url').value = fullUrl;
document.getElementById('share-result-open-btn').href = fullUrl;
document.getElementById('share-result-box').style.display = 'block';
showToast(mode === 'showcase' ? 'Showcase link created!' : 'Share link created successfully!', 'success');
loadActiveShares();
} catch (err) {
showToast('Error creating share: ' + err, 'error');
}
}
function copyShareUrl() {
const input = document.getElementById('share-result-url');
if (input && input.value) {
navigator.clipboard.writeText(input.value);
showToast('Share URL copied to clipboard!', 'success');
}
}
function openSharesManager() {
const toolsMenu = document.getElementById('tools-dropdown-menu');
if (toolsMenu) toolsMenu.classList.remove('active');
const win = document.getElementById('floating-shares-window');
const body = document.getElementById('shares-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('shares-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initSharesDragResize();
setupSharesDropzone();
loadActiveShares();
}
function closeFloatingShares() {
const win = document.getElementById('floating-shares-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('shares-pill');
if (pill) pill.style.display = 'none';
}
function minimizeFloatingShares() {
const win = document.getElementById('floating-shares-window');
const pill = document.getElementById('shares-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingShares() {
openSharesManager();
}
function toggleMaximizeShares() {
const win = document.getElementById('floating-shares-window');
if (win) win.classList.toggle('maximized');
}
function dockSharesToActivePane() {
closeFloatingShares();
App.panes[App.activePaneIndex].dockedTool = 'shares';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'shares');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function mountDockedShares(paneIndex) {
const host = document.getElementById(`docked-shares-host-${paneIndex}`);
const floatingBody = document.getElementById('shares-body');
if (host && floatingBody) {
host.appendChild(floatingBody);
setupSharesDropzone();
loadActiveShares();
if (window.lucide) lucide.createIcons();
}
}
function setupSharesDropzone(customWin = null) {
const win = customWin || document.getElementById('floating-shares-window');
const body = document.getElementById('shares-body');
const targetElements = [win, body].filter(Boolean);
targetElements.forEach(el => {
if (el._sharesDropzoneBound) return;
el._sharesDropzoneBound = true;
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (win) win.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
if (!el.contains(e.relatedTarget)) {
if (win) win.classList.remove('drag-over');
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (win) win.classList.remove('drag-over');
let droppedPaths = [];
const plainText = e.dataTransfer.getData('text/plain');
if (plainText) {
try {
const data = JSON.parse(plainText);
if (data.paths && data.paths.length > 0) {
droppedPaths = data.paths;
}
} catch (_) {
if (plainText.startsWith('/') || plainText.startsWith('~')) {
droppedPaths = [plainText.trim()];
}
}
}
if (droppedPaths.length === 0 && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
droppedPaths = Array.from(e.dataTransfer.files).map(f => f.path).filter(Boolean);
}
if (droppedPaths.length === 0) return;
const firstPath = droppedPaths[0];
const itemName = firstPath.split('/').filter(Boolean).pop() || 'item';
App.contextItem = {
name: itemName,
path: firstPath,
is_dir: false
};
triggerShare();
});
});
}
function initSharesDragResize() {
const win = document.getElementById('floating-shares-window');
const header = document.getElementById('shares-header');
if (!win || !header) return;
const savedX = localStorage.getItem('cd_shares_x');
const savedY = localStorage.getItem('cd_shares_y');
const savedW = localStorage.getItem('cd_shares_w');
const savedH = localStorage.getItem('cd_shares_h');
if (window.innerWidth > 1024) {
if (savedW) win.style.width = `${Math.min(parseInt(savedW, 10), window.innerWidth - 20)}px`;
if (savedH) win.style.height = `${Math.min(parseInt(savedH, 10), window.innerHeight - 50)}px`;
if (savedX && savedY) {
const maxX = Math.max(10, window.innerWidth - (parseInt(savedW, 10) || 780) - 20);
const maxY = Math.max(40, window.innerHeight - (parseInt(savedH, 10) || 580) - 20);
win.style.left = `${Math.min(Math.max(10, parseInt(savedX, 10)), maxX)}px`;
win.style.top = `${Math.min(Math.max(40, parseInt(savedY, 10)), maxY)}px`;
}
}
if (header._sharesDragBound) return;
header._sharesDragBound = true;
let isDragging = false;
let startX = 0, startY = 0, initialLeft = 0, initialTop = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('button') || e.target.closest('input') || e.target.closest('select') || e.target.closest('a')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
bringFloatingWindowToFront(win);
e.preventDefault();
});
let isResizing = false;
let currentResizeDir = '';
let initialW = 0, initialH = 0;
const resizeHandles = [
{ el: document.getElementById('shares-resize-top'), dir: 'top' },
{ el: document.getElementById('shares-resize-bottom'), dir: 'bottom' },
{ el: document.getElementById('shares-resize-left'), dir: 'left' },
{ el: document.getElementById('shares-resize-right'), dir: 'right' },
{ el: document.getElementById('shares-resize-corner'), dir: 'corner' },
];
resizeHandles.forEach(({ el, dir }) => {
if (!el) return;
el.addEventListener('mousedown', (e) => {
if (win.classList.contains('maximized')) return;
isResizing = true;
currentResizeDir = dir;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
initialW = rect.width;
initialH = rect.height;
document.body.style.userSelect = 'none';
if (dir === 'top' || dir === 'bottom') document.body.style.cursor = 'ns-resize';
else if (dir === 'left' || dir === 'right') document.body.style.cursor = 'ew-resize';
else document.body.style.cursor = 'nwse-resize';
bringFloatingWindowToFront(win);
e.preventDefault();
e.stopPropagation();
});
});
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, initialLeft + dx));
const newTop = Math.max(36, Math.min(window.innerHeight - win.offsetHeight, initialTop + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
}
if (isResizing) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (currentResizeDir === 'corner' || currentResizeDir === 'right') {
const newW = Math.max(420, Math.min(window.innerWidth - initialLeft - 10, initialW + dx));
win.style.width = `${newW}px`;
}
if (currentResizeDir === 'corner' || currentResizeDir === 'bottom') {
const newH = Math.max(360, Math.min(window.innerHeight - initialTop - 10, initialH + dy));
win.style.height = `${newH}px`;
}
if (currentResizeDir === 'left') {
const newW = Math.max(420, initialW - dx);
if (newW !== initialW - dx) return;
const newLeft = initialLeft + dx;
if (newLeft > 0) {
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
}
if (currentResizeDir === 'top') {
const newH = Math.max(360, initialH - dy);
if (newH !== initialH - dy) return;
const newTop = initialTop + dy;
if (newTop > 36) {
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_shares_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_shares_y', parseInt(win.style.top, 10));
}
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_shares_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_shares_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_shares_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_shares_y', parseInt(win.style.top, 10));
}
});
setupSharesDropzone(win);
}
async function loadActiveShares() {
const tbody = document.getElementById('shares-table-tbody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:var(--text-muted); padding:24px;">Loading active shares...</td></tr>';
}
try {
const resp = await fetch('/api/shares', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; color:var(--danger); padding:20px;">Failed to load shares: ${await resp.text()}</td></tr>`;
return;
}
allLoadedShares = await resp.json() || [];
// Update KPI counters
const activeCount = allLoadedShares.filter(s => s.status === 'active').length;
const totalVisits = allLoadedShares.reduce((acc, s) => acc + (s.total_visits || 0), 0);
const totalPreviews = allLoadedShares.reduce((acc, s) => acc + (s.total_previews || 0), 0);
const totalDownloads = allLoadedShares.reduce((acc, s) => acc + (s.download_count || 0), 0);
const kpiActive = document.getElementById('kpi-shares-active');
if (kpiActive) kpiActive.textContent = activeCount;
const kpiVisits = document.getElementById('kpi-shares-visits');
if (kpiVisits) kpiVisits.textContent = totalVisits;
const kpiPreviews = document.getElementById('kpi-shares-previews');
if (kpiPreviews) kpiPreviews.textContent = totalPreviews;
const kpiDownloads = document.getElementById('kpi-shares-downloads');
if (kpiDownloads) kpiDownloads.textContent = totalDownloads;
const countBadge = document.getElementById('shares-count-badge');
if (countBadge) countBadge.textContent = `${activeCount} Active`;
handleSharesFilterChange();
renderDockedSharesList();
} catch (err) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; color:var(--danger); padding:20px;">Network error loading shares</td></tr>`;
}
}
function handleSharesFilterChange() {
const search = (document.getElementById('shares-search-input')?.value || '').toLowerCase().trim();
const statusFilter = document.getElementById('shares-status-filter')?.value || 'active';
let filtered = allLoadedShares.filter(s => {
// Search query match
const matchSearch = !search || s.name.toLowerCase().includes(search) || s.path.toLowerCase().includes(search);
if (!matchSearch) return false;
// Status / Mode filter
if (statusFilter === 'active') return s.status === 'active';
if (statusFilter === 'showcase') return !s.allow_download;
if (statusFilter === 'dropbox') return s.allow_upload;
if (statusFilter === 'revoked') return s.status === 'revoked';
return true; // 'all'
});
renderSharesTable(filtered);
}
function renderSharesTable(shares) {
const tbody = document.getElementById('shares-table-tbody');
if (!tbody) return;
if (!shares || shares.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:var(--text-muted); padding:24px;">No matching shares found.</td></tr>';
return;
}
tbody.innerHTML = shares.map(s => {
let modeIcon = s.is_dir ? '📁' : '📄';
let modeTitle = 'Standard Share';
if (!s.allow_download) {
modeIcon = '🖼️';
modeTitle = 'Showcase Mode (View Only)';
} else if (s.allow_upload) {
modeIcon = '📥';
modeTitle = 'Guest Dropbox';
}
// Access Gates
const gates = [];
if (s.has_password) gates.push('<span title="Password Protected" style="color:var(--accent);">🔒 Pass</span>');
if (s.require_email) gates.push('<span title="Email Whitelist Restricted" style="color:#60a5fa;">🛡️ Email</span>');
if (s.watermark_enabled) gates.push('<span title="Dynamic Watermark" style="color:#10b981;">💧 Watermark</span>');
const gatesHtml = gates.length > 0 ? gates.join(' ') : '<span style="color:var(--text-dim);">Public</span>';
// Status Badge
let statusHtml = '<span class="badge badge-showcase">Active</span>';
if (s.status === 'revoked') {
statusHtml = '<span class="badge" style="background:rgba(239,68,68,0.15); color:#ef4444; border:1px solid rgba(239,68,68,0.3);">Revoked</span>';
} else if (s.expires_at && new Date(s.expires_at) < new Date()) {
statusHtml = '<span class="badge" style="background:rgba(234,179,8,0.15); color:#eab308; border:1px solid rgba(234,179,8,0.3);">Expired</span>';
} else if (s.max_downloads > 0 && s.download_count >= s.max_downloads) {
statusHtml = '<span class="badge" style="background:rgba(156,163,175,0.15); color:#9ca3af; border:1px solid rgba(156,163,175,0.3);">Limit</span>';
}
const expiryStr = s.expires_at ? new Date(s.expires_at).toLocaleDateString() : 'Never';
const fullUrl = `${window.location.origin}/share/${s.token}`;
const activityStats = `
<div style="font-size:11px; display:flex; gap:8px; justify-content:center;">
<span title="Visits" style="color:#60a5fa;">👁️ ${s.total_visits || 0}</span>
<span title="Previews" style="color:#10b981;">🖼️ ${s.total_previews || 0}</span>
<span title="Downloads" style="color:var(--accent);">⬇️ ${s.download_count || 0}</span>
</div>
`;
const isRevoked = s.status === 'revoked';
return `
<tr class="file-row">
<td style="font-size:18px; text-align:center;" title="${modeTitle}">${modeIcon}</td>
<td>
<div style="font-weight:700; font-size:12px; color:var(--text-main);">${escapeHtml(s.name)}</div>
<div style="font-size:10px; color:var(--text-muted); font-family:var(--font-mono);">${escapeHtml(s.path)}</div>
</td>
<td style="font-size:11px;">${gatesHtml}</td>
<td>${statusHtml}</td>
<td style="font-size:11px; color:var(--text-muted);">${expiryStr}</td>
<td style="text-align:center;">${activityStats}</td>
<td style="text-align:right; white-space:nowrap;">
<button class="btn btn-icon" style="width:26px; height:26px;" onclick="navigator.clipboard.writeText('${fullUrl}'); showToast('Share URL copied!', 'success');" title="Copy Public URL"><i data-lucide="copy" style="width:12px;"></i></button>
<a href="${fullUrl}" target="_blank" class="btn btn-icon" style="width:26px; height:26px; text-decoration:none;" title="Open Share Link"><i data-lucide="external-link" style="width:12px;"></i></a>
<button class="btn btn-icon" style="width:26px; height:26px;" onclick="openShareLogs(${s.id}, '${escapeHtml(s.name)}')" title="Visitor Audit Trail"><i data-lucide="activity" style="width:12px; color:#60a5fa;"></i></button>
<button class="btn btn-icon" style="width:26px; height:26px;" onclick="toggleRevokeShare(${s.id}, ${isRevoked})" title="${isRevoked ? 'Reactivate Share' : 'Revoke Share'}" style="color:${isRevoked ? '#10b981' : '#f59e0b'};"><i data-lucide="${isRevoked ? 'check-circle-2' : 'ban'}" style="width:12px;"></i></button>
<button class="btn btn-icon btn-danger-hover" style="width:26px; height:26px;" onclick="deleteSharePermanent(${s.id})" title="Delete Share Permanently" style="color:var(--danger);"><i data-lucide="trash-2" style="width:12px;"></i></button>
</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function renderDockedSharesList() {
App.panes.forEach((pane, idx) => {
if (pane && pane.dockedTool === 'shares') {
const mount = document.getElementById(`docked-shares-list-${idx}`);
if (!mount) return;
if (!allLoadedShares || allLoadedShares.length === 0) {
mount.innerHTML = '<div style="text-align:center; color:var(--text-muted); padding:20px; font-size:11px;">No active shares. Click "+ New" to share a file or folder.</div>';
return;
}
mount.innerHTML = allLoadedShares.map(s => {
const fullUrl = `${window.location.origin}/share/${s.token}`;
const isRevoked = s.status === 'revoked';
const modeIcon = s.is_dir ? '📁' : '📄';
return `
<div style="background:var(--bg-panel); border:1px solid var(--border); border-radius:6px; padding:8px 10px; margin-bottom:6px; display:flex; align-items:center; justify-content:space-between; gap:8px;">
<div style="display:flex; align-items:center; gap:8px; min-width:0; flex:1;">
<span style="font-size:16px;">${modeIcon}</span>
<div style="min-width:0; flex:1;">
<div style="font-weight:600; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--text-main);">${escapeHtml(s.name)}</div>
<div style="font-size:9px; color:var(--text-muted); font-family:var(--font-mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(s.path)}</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:3px;">
<button class="btn btn-icon" style="width:22px; height:22px;" onclick="navigator.clipboard.writeText('${fullUrl}'); showToast('Share URL copied!', 'success');" title="Copy URL"><i data-lucide="copy" style="width:11px;"></i></button>
<button class="btn btn-icon" style="width:22px; height:22px;" onclick="openShareLogs(${s.id}, '${escapeHtml(s.name)}')" title="Audit Trail"><i data-lucide="activity" style="width:11px; color:#60a5fa;"></i></button>
<button class="btn btn-icon" style="width:22px; height:22px;" onclick="toggleRevokeShare(${s.id}, ${isRevoked})" title="${isRevoked ? 'Reactivate' : 'Revoke'}"><i data-lucide="${isRevoked ? 'check-circle-2' : 'ban'}" style="width:11px;"></i></button>
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
});
}
async function toggleRevokeShare(id, isRevoked) {
const actionName = isRevoked ? 'reactivate' : 'revoke';
const confirmed = await showConfirmDialog({
title: `${isRevoked ? 'Reactivate' : 'Revoke'} Public Share`,
subtitle: 'Public Sharing Link Access',
message: `Are you sure you want to ${actionName} this public share link?`,
icon: isRevoked ? 'check-circle' : 'shield-alert',
type: isRevoked ? 'info' : 'warning',
confirmText: isRevoked ? 'Reactivate Link' : 'Revoke Link'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/shares/${id}/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ revoke: !isRevoked })
});
if (resp.ok) {
showToast(`Share link ${isRevoked ? 'reactivated' : 'revoked'} successfully!`, 'info');
loadActiveShares();
} else {
showToast('Failed to update share: ' + await resp.text(), 'error');
}
} catch (err) {
showToast('Error updating share: ' + err, 'error');
}
}
async function deleteSharePermanent(id) {
const confirmed = await showConfirmDialog({
title: 'Delete Share Record',
subtitle: 'Permanent Deletion & Audit Removal',
message: 'Are you sure you want to permanently delete this share record and its audit history? This action cannot be undone.',
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete Permanently'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/shares/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast('Share deleted permanently', 'info');
loadActiveShares();
} else {
showToast('Failed to delete share: ' + await resp.text(), 'error');
}
} catch (err) {
showToast('Error deleting share: ' + err, 'error');
}
}
async function openShareLogs(id, name) {
const titleEl = document.getElementById('share-logs-title');
if (titleEl) titleEl.textContent = name;
const tbody = document.getElementById('share-logs-tbody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:20px;">Loading access logs...</td></tr>';
}
showModal('share-logs-modal');
try {
const resp = await fetch(`/api/shares/${id}/logs`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--danger); padding:16px;">Failed to load logs: ${await resp.text()}</td></tr>`;
return;
}
const logs = await resp.json() || [];
if (logs.length === 0) {
if (tbody) tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:24px;">No visitor interactions logged yet for this share.</td></tr>';
return;
}
if (tbody) {
tbody.innerHTML = logs.map(l => {
let actionBadge = `<span class="badge" style="background:rgba(59,130,246,0.15); color:#60a5fa;">Visit</span>`;
if (l.action === 'preview') actionBadge = `<span class="badge" style="background:rgba(16,185,129,0.15); color:#10b981;">Preview</span>`;
else if (l.action === 'download') actionBadge = `<span class="badge" style="background:rgba(245,158,11,0.15); color:var(--accent);">Download</span>`;
else if (l.action === 'upload') actionBadge = `<span class="badge" style="background:rgba(168,85,247,0.15); color:#c084fc;">Upload</span>`;
const visitorInfo = l.visitor_email
? `<div><strong>${escapeHtml(l.visitor_email)}</strong></div><div style="font-size:10px; color:var(--text-muted); font-family:var(--font-mono);">${escapeHtml(l.ip_address)}</div>`
: `<span style="font-family:var(--font-mono); font-size:11px;">${escapeHtml(l.ip_address)}</span>`;
const targetFileStr = l.target_file ? escapeHtml(l.target_file) : '<span style="color:var(--text-dim);">-</span>';
const uaStr = l.user_agent ? `<div style="font-size:10px; color:var(--text-muted); max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${escapeHtml(l.user_agent)}">${escapeHtml(l.user_agent)}</div>` : '-';
return `
<tr class="file-row">
<td style="font-size:11px; color:var(--text-muted); white-space:nowrap;">${new Date(l.accessed_at).toLocaleString()}</td>
<td>${actionBadge}</td>
<td>${visitorInfo}</td>
<td style="font-size:11px; font-weight:600;">${targetFileStr}</td>
<td>${uaStr}</td>
</tr>
`;
}).join('');
}
if (window.lucide) lucide.createIcons();
} catch (err) {
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--danger); padding:16px;">Network error loading audit logs</td></tr>`;
}
}
// ---------------- ARCHIVE COMPRESSOR & EXTRACTOR ----------------
let pendingCompressSources = [];
function triggerCompressModal() {
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
let paths = [];
if (pane.selected.size > 0) {
paths = Array.from(pane.selected);
} else if (App.contextItem) {
paths = [App.contextItem.path];
} else if (pane.entries && pane.entries[pane.cursorIndex]) {
paths = [pane.entries[pane.cursorIndex].path];
}
if (paths.length === 0) {
showToast('Please select files or folders to compress', 'info');
return;
}
pendingCompressSources = paths;
const singleName = paths[0].split('/').filter(Boolean).pop() || 'archive';
const baseName = paths.length === 1 ? singleName : 'archive';
const defaultArchiveName = `${baseName.replace(/\.[^/.]+$/, '')}.zip`;
document.getElementById('compress-summary').innerHTML = `Compressing <strong>${paths.length} item(s)</strong>`;
document.getElementById('compress-name-input').value = defaultArchiveName;
document.getElementById('compress-format-select').value = 'zip';
document.getElementById('compress-dest-input').value = pane.path;
showModal('archive-compress-modal');
}
function setCompressDestToOpposite() {
const oppIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const oppPane = App.panes[oppIdx];
if (oppPane) {
document.getElementById('compress-dest-input').value = oppPane.path;
}
}
function updateCompressExt(format) {
const nameInput = document.getElementById('compress-name-input');
if (!nameInput) return;
const current = nameInput.value;
const raw = current.replace(/(\.zip|\.tar\.gz|\.tar|\.tar\.bz2|\.tar\.xz)$/i, '');
if (format === 'zip') {
nameInput.value = `${raw}.zip`;
} else if (format === 'targz') {
nameInput.value = `${raw}.tar.gz`;
}
}
async function executeCompressArchive() {
if (pendingCompressSources.length === 0) return;
const name = document.getElementById('compress-name-input').value.trim();
const format = document.getElementById('compress-format-select').value;
const destDir = document.getElementById('compress-dest-input').value.trim();
if (!name) {
showToast('Please specify an archive name', 'error');
return;
}
const targetPath = `${destDir.replace(/\/$/, '')}/${name}`;
closeModal('archive-compress-modal');
showToast(`Creating ${format.toUpperCase()} archive in background...`, 'info');
try {
const endpoint = getPaneEndpoint(App.activePaneIndex);
const headers = getPaneAuthHeaders(App.activePaneIndex, { 'Content-Type': 'application/json' });
const resp = await fetch(`${endpoint}/api/fs/archive/create`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
sources: pendingCompressSources,
target_path: targetPath,
format: format
})
});
if (resp.ok) {
showToast(`Archive created successfully: ${name}`, 'success');
refreshAllPanes();
} else {
showToast(`Archive creation failed: ${await resp.text()}`, 'error');
}
} catch (err) {
showToast(`Archive creation error: ${err}`, 'error');
}
}
let pendingExtractItem = null;
function triggerExtractModal() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || (pane.entries && pane.entries[pane.cursorIndex]);
if (!item) return;
pendingExtractItem = item;
document.getElementById('extract-archive-name').textContent = item.name;
document.getElementById('extract-archive-path').textContent = item.path;
const rawSubName = item.name.replace(/(\.zip|\.tar\.gz|\.tgz|\.tar\.bz2|\.tar\.xz|\.7z|\.tar|\.cbz|\.epub)$/i, '');
const subLabel = document.getElementById('extract-subfolder-label');
if (subLabel) subLabel.textContent = rawSubName;
document.getElementById('extract-mode-select').value = 'here';
document.getElementById('extract-custom-path-group').style.display = 'none';
document.getElementById('extract-target-dir-input').value = pane.path;
showModal('archive-extract-modal');
}
function handleExtractModeChange(mode) {
const customGroup = document.getElementById('extract-custom-path-group');
if (customGroup) {
customGroup.style.display = mode === 'custom' ? 'block' : 'none';
}
}
async function executeExtractArchive() {
if (!pendingExtractItem) return;
const pane = App.panes[App.activePaneIndex];
const mode = document.getElementById('extract-mode-select').value;
let targetDir = pane.path;
if (mode === 'subfolder') {
const rawSubName = pendingExtractItem.name.replace(/(\.zip|\.tar\.gz|\.tgz|\.tar\.bz2|\.tar\.xz|\.7z|\.tar|\.cbz|\.epub)$/i, '');
targetDir = joinItemPath(pane.path, rawSubName);
} else if (mode === 'opposite') {
const oppIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
targetDir = App.panes[oppIdx].path;
} else if (mode === 'custom') {
targetDir = document.getElementById('extract-target-dir-input').value.trim();
}
closeModal('archive-extract-modal');
showToast(`Extracting ${pendingExtractItem.name}...`, 'info');
try {
const endpoint = getPaneEndpoint(App.activePaneIndex);
const headers = getPaneAuthHeaders(App.activePaneIndex, { 'Content-Type': 'application/json' });
const resp = await fetch(`${endpoint}/api/fs/archive/extract`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
archive_path: pendingExtractItem.path,
target_dir: targetDir
})
});
if (resp.ok) {
showToast(`Extracted ${pendingExtractItem.name} successfully!`, 'success');
refreshAllPanes();
} else {
showToast(`Extraction failed: ${await resp.text()}`, 'error');
}
} catch (err) {
showToast(`Extraction error: ${err}`, 'error');
}
}
function triggerArchiveZip() {
triggerCompressModal();
}
function triggerArchiveTarGz() {
triggerCompressModal();
}
function triggerExtract() {
triggerExtractModal();
}
// ---------------- MULTI-FILE & BATCH DOWNLOAD ----------------
async function triggerDownload() {
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
let paths = [];
if (pane.selected.size > 0) {
paths = Array.from(pane.selected);
} else if (App.contextItem) {
paths = [App.contextItem.path];
} else if (pane.entries && pane.entries[pane.cursorIndex]) {
paths = [pane.entries[pane.cursorIndex].path];
}
if (paths.length === 0) {
showToast('No files or folders selected for download', 'info');
return;
}
// If exactly 1 file selected and it is NOT a directory
if (paths.length === 1) {
const singlePath = paths[0];
const entry = pane.entries.find(e => e.path === singlePath);
if (entry && !entry.is_dir) {
window.open(`/api/fs/download?path=${encodeURIComponent(singlePath)}`, '_blank');
showToast('Download started', 'success');
return;
}
}
// Multiple files or folder selected -> batch download as dynamic zip archive
await downloadBatchArchive(paths);
}
async function downloadBatchArchive(paths) {
showToast('Creating batch zip archive for download...', 'info');
try {
const resp = await fetch('/api/fs/download/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ paths })
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(errText || 'Failed to create batch archive');
}
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const cd = resp.headers.get('content-disposition');
let filename = 'brum_download.zip';
if (cd && cd.includes('filename=')) {
const match = cd.match(/filename=["']?([^"';]+)["']?/);
if (match && match[1]) filename = match[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
showToast('Batch download archive ready!', 'success');
} catch (err) {
await showAlertDialog({
title: 'Download Failed',
message: 'Failed to generate batch zip archive: ' + err.message,
icon: 'alert-triangle',
type: 'error',
okText: 'Close'
});
}
}
// ---------------- PANE IDENTIFICATION BORDER COLORS ----------------
function getPaneColors() {
try {
return JSON.parse(localStorage.getItem('cd_pane_colors')) || {};
} catch (e) {
return {};
}
}
function setPaneColorPref(paneIdx, colorVal) {
const colors = getPaneColors();
if (colorVal === 'default' || !colorVal) {
delete colors[paneIdx];
} else {
colors[paneIdx] = colorVal;
}
localStorage.setItem('cd_pane_colors', JSON.stringify(colors));
applyPaneColors();
updatePaneTitles();
queueSaveUserPreferencesToServer();
}
function cyclePaneColor(paneIdx) {
const colors = getPaneColors();
const current = colors[paneIdx] || 'default';
const currentKey = (typeof current === 'string' && PANE_COLOR_PALETTE.includes(current)) ? current : 'default';
const curIdx = PANE_COLOR_PALETTE.indexOf(currentKey);
const nextColor = PANE_COLOR_PALETTE[(curIdx + 1) % PANE_COLOR_PALETTE.length];
setPaneColorPref(paneIdx, nextColor);
const name = PANE_COLOR_PRESETS[nextColor]?.name || nextColor;
showToast(`Pane ${paneIdx + 1} color: ${name}`, 'info');
}
function openPaneToolsMenu(e, paneIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const existing = document.getElementById('pane-tools-popup');
const wasOpenForThisPane = existing && existing.dataset.paneIndex === String(paneIndex);
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
if (wasOpenForThisPane) return;
const colors = getPaneColors();
const globalAngle = localStorage.getItem('cd_border_angle') || '45deg';
const currentColorConf = colors[paneIndex] || 'default';
const resolved = resolvePaneColorConfig(currentColorConf, globalAngle);
const activeHex = resolved.activeC1;
const popup = document.createElement('div');
popup.id = 'pane-tools-popup';
popup.dataset.paneIndex = String(paneIndex);
popup.className = 'pane-tools-dropdown active';
popup.innerHTML = `
<div style="padding: 8px 12px; font-weight: 700; font-size: 11px; color: var(--accent); background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<span style="display: flex; align-items: center; gap: 6px;">
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:${activeHex};"></span>
Pane ${paneIndex + 1} Actions
</span>
<span style="font-size: 11px; color: var(--text-dim); cursor: pointer;" onclick="document.getElementById('pane-tools-popup')?.remove();">✕</span>
</div>
<div class="pane-dropdown-body" style="padding: 4px 0;">
<!-- Ingest & Transfers -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Ingest & Transfers</div>
<div class="dropdown-item" onclick="setActivePane(${paneIndex}); triggerCopy(); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="arrow-right-left"></i> <span>Transfer / Copy to Other Pane (F5)</span>
</div>
<div class="dropdown-item" onclick="setActivePane(${paneIndex}); triggerMove(); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="arrow-right"></i> <span>Transfer / Move to Other Pane (F6)</span>
</div>
<div class="dropdown-item" onclick="triggerDeviceUpload(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="upload"></i> <span>Upload Files from Device...</span>
</div>
<div class="dropdown-item" onclick="triggerDeviceFolderUpload(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="folder-up"></i> <span>Upload Folder from Device...</span>
</div>
<div class="dropdown-item" onclick="triggerDownloadCurrentDirectory(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="download"></i> <span>Download Folder (.zip)</span>
</div>
<div class="dropdown-item" onclick="triggerShareDirectory(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="share-2"></i> <span>Share Folder / Dropbox...</span>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
<!-- View & Navigation -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">View & Navigation</div>
<div class="dropdown-item" onclick="document.getElementById('pane-tools-popup')?.remove(); togglePaneFilter(${paneIndex});">
<i data-lucide="filter"></i> <span>Toggle Quick Filter (/ or Ctrl+F)</span>
</div>
<div class="dropdown-item" onclick="togglePaneTree(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="folder-tree"></i> <span>Toggle Folder Tree Sidebar (${App.panes[paneIndex]?.showTree ? 'Hide' : 'Show'})</span>
</div>
<div class="dropdown-item" onclick="toggleBranchView(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="git-branch"></i> <span>Toggle Flat Branch View (${App.panes[paneIndex]?.isBranchView ? 'Disable' : 'Enable'})</span>
</div>
<div class="dropdown-item" onclick="toggleSyncScroll(); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="unfold-vertical"></i> <span>Toggle Synchronized Scrolling (${App.syncScroll ? 'Disable' : 'Enable'})</span>
</div>
<div class="dropdown-item" onclick="toggleSyncNav(); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="git-compare"></i> <span>Toggle Synchronized Navigation (${App.syncNav ? 'Disable' : 'Enable'})</span>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
<!-- Pane Customization -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Pane Customization</div>
<div class="dropdown-item" onclick="document.getElementById('pane-tools-popup')?.remove(); openPaneSettingsMenu(event, ${paneIndex});">
<i data-lucide="sliders"></i> <span>Pane Settings & Customizer...</span>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-tools-popup')?.remove(); openPaneFavoritesMenu(event, ${paneIndex});">
<i data-lucide="compass"></i> <span>Places, Bookmarks & Fleet...</span>
</div>
<div class="dropdown-item" onclick="document.getElementById('pane-tools-popup')?.remove(); openBookmarksManager();">
<i data-lucide="bookmark"></i> <span>Bookmarks Manager...</span>
</div>
<div class="dropdown-item" onclick="triggerDirPermissions(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="shield-check"></i> <span>Permissions & Ownership</span>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
<!-- Storage & Remote -->
<div style="padding: 6px 12px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px;">Storage & Remote</div>
${App.panes[paneIndex]?.path?.includes('://') ? `
<div class="dropdown-item" onclick="disconnectPaneRemote(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();" style="color: var(--danger, #ef4444); background: rgba(239,68,68,0.08);">
<i data-lucide="log-out" style="color: var(--danger, #ef4444);"></i> <span>Disconnect Remote Connection</span>
</div>
<div class="dropdown-sep" style="height: 1px; background: var(--border); margin: 4px 0;"></div>
` : ''}
<div class="dropdown-item" onclick="openRemoteModal(${paneIndex}); document.getElementById('pane-tools-popup')?.remove();">
<i data-lucide="network"></i> <span>Connect Remote Storage (SFTP/SMB/WebDAV)...</span>
</div>
</div>
`;
const btn = document.getElementById(`pane-tools-btn-${paneIndex}`);
if (btn) {
const rect = btn.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = `${rect.bottom + 4}px`;
popup.style.right = `${Math.max(10, window.innerWidth - rect.right)}px`;
}
document.body.appendChild(popup);
if (window.lucide) lucide.createIcons();
const closeHandler = (ev) => {
if (!popup.contains(ev.target) && !btn?.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closeHandler);
document.removeEventListener('touchstart', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
document.addEventListener('touchstart', closeHandler);
}, 10);
}
function handlePaneBadgeDragStart(e, index) {
if (App.paneReorderEnabled === false) {
e.preventDefault();
return;
}
window._draggingPaneIndex = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `pane:${index}`);
const badgeBtn = document.getElementById(`pane-badge-btn-${index}`);
if (badgeBtn) badgeBtn.classList.add('dragging-pane-badge');
const grid = document.getElementById('panes-grid');
if (grid) grid.classList.add('pane-reorder-active');
}
function handlePaneBadgeDragEnd(e, index) {
window._draggingPaneIndex = null;
document.querySelectorAll('.dragging-pane-badge').forEach(b => b.classList.remove('dragging-pane-badge'));
document.querySelectorAll('.pane-reorder-target').forEach(p => p.classList.remove('pane-reorder-target'));
const grid = document.getElementById('panes-grid');
if (grid) grid.classList.remove('pane-reorder-active');
}
function togglePaneReordering(enabled) {
App.paneReorderEnabled = !!enabled;
localStorage.setItem('cd_pane_reorder_enabled', enabled ? 'true' : 'false');
queueSaveUserPreferencesToServer();
renderAllPanes();
showToast(enabled ? 'Panel drag-and-drop rearrangement enabled' : 'Panel drag-and-drop rearrangement disabled', 'info');
}
function toggleActivateOnHover(enabled) {
App.activateOnHover = !!enabled;
localStorage.setItem('cd_activate_on_hover', enabled ? '1' : '0');
queueSaveUserPreferencesToServer();
showToast(enabled ? 'Panel activation on hover enabled' : 'Panel activation on hover disabled', 'info');
}
function swapPanes(srcIdx, targetIdx) {
if (srcIdx === targetIdx || srcIdx < 0 || targetIdx < 0 || srcIdx >= App.panes.length || targetIdx >= App.panes.length) {
return;
}
// 1. Swap in App.panes array
const tempPane = App.panes[srcIdx];
App.panes[srcIdx] = App.panes[targetIdx];
App.panes[targetIdx] = tempPane;
// Update their internal IDs
App.panes[srcIdx].id = srcIdx;
App.panes[targetIdx].id = targetIdx;
// 2. Swap localStorage persistent keys for paths, viewmode, gridsize, tree, docked, sync
const keys = ['cd_pane_path', 'cd_pane_viewmode', 'cd_pane_gridsize', 'cd_pane_tree', 'cd_pane_docked', 'cd_pane_sync_scroll', 'cd_pane_sync_nav'];
keys.forEach(prefix => {
const valSrc = localStorage.getItem(`${prefix}_${srcIdx}`);
const valTarget = localStorage.getItem(`${prefix}_${targetIdx}`);
if (valTarget !== null) localStorage.setItem(`${prefix}_${srcIdx}`, valTarget);
else localStorage.removeItem(`${prefix}_${srcIdx}`);
if (valSrc !== null) localStorage.setItem(`${prefix}_${targetIdx}`, valSrc);
else localStorage.removeItem(`${prefix}_${targetIdx}`);
});
// 3. Swap pane colors
const colors = getPaneColors();
const colorSrc = colors[srcIdx];
const colorTarget = colors[targetIdx];
if (colorTarget !== undefined) colors[srcIdx] = colorTarget;
else delete colors[srcIdx];
if (colorSrc !== undefined) colors[targetIdx] = colorSrc;
else delete colors[targetIdx];
localStorage.setItem('cd_pane_colors', JSON.stringify(colors));
// 4. Update custom names storage
savePaneCustomNames();
// 5. Update active pane index
if (App.activePaneIndex === srcIdx) {
App.activePaneIndex = targetIdx;
} else if (App.activePaneIndex === targetIdx) {
App.activePaneIndex = srcIdx;
}
// 6. Re-render UI and save cloud preferences
renderAllPanes();
updatePaneTitles();
queueSaveUserPreferencesToServer();
showToast(`Swapped Pane ${srcIdx + 1} with Pane ${targetIdx + 1}`, 'info');
}
let _paneBadgeTouchTimer = null;
let _paneBadgeLongPressed = false;
let _paneBadgeTouchStartX = 0;
let _paneBadgeTouchStartY = 0;
function handlePaneBadgeTouchStart(e, paneIndex) {
_paneBadgeLongPressed = false;
if (!e.touches || e.touches.length === 0) return;
_paneBadgeTouchStartX = e.touches[0].clientX;
_paneBadgeTouchStartY = e.touches[0].clientY;
clearTimeout(_paneBadgeTouchTimer);
_paneBadgeTouchTimer = setTimeout(() => {
_paneBadgeLongPressed = true;
triggerHaptic(40);
openPaneSettingsMenu(null, paneIndex);
}, 450);
}
function handlePaneBadgeTouchMove(e, paneIndex) {
if (!_paneBadgeTouchTimer) return;
if (!e.touches || e.touches.length === 0) return;
const dx = Math.abs(e.touches[0].clientX - _paneBadgeTouchStartX);
const dy = Math.abs(e.touches[0].clientY - _paneBadgeTouchStartY);
if (dx > 10 || dy > 10) {
clearTimeout(_paneBadgeTouchTimer);
_paneBadgeTouchTimer = null;
}
}
function handlePaneBadgeTouchEnd(e, paneIndex) {
clearTimeout(_paneBadgeTouchTimer);
_paneBadgeTouchTimer = null;
if (_paneBadgeLongPressed) {
if (e && e.cancelable) e.preventDefault();
if (e) e.stopPropagation();
setTimeout(() => { _paneBadgeLongPressed = false; }, 150);
}
}
function handlePaneBadgeTouchCancel(e, paneIndex) {
clearTimeout(_paneBadgeTouchTimer);
_paneBadgeTouchTimer = null;
_paneBadgeLongPressed = false;
}
function updatePhoneTabletMaxPanes(val) {
const num = parseInt(val, 10) || 2;
App.phoneTabletMaxPanes = num;
localStorage.setItem('cd_phone_tablet_max_panes', num.toString());
queueSaveUserPreferencesToServer();
renderAllPanes();
showToast(`Phone/Tablet maximum panels set to ${num}`, 'info');
}
function handlePaneBadgeClick(e, paneIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (_paneBadgeLongPressed) {
_paneBadgeLongPressed = false;
return;
}
// On phone / narrow mobile viewports (<= 600px), tapping the pane badge cycles to the other active pane
if (window.innerWidth <= 600) {
const visibleCount = getVisiblePaneCount();
if (visibleCount > 1) {
const nextPane = (paneIndex + 1) % visibleCount;
setActivePane(nextPane);
return;
}
}
openPaneSettingsMenu(e, paneIndex);
}
function openPaneSettingsMenu(e, paneIndex) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
const existing = document.getElementById('pane-settings-popup');
const wasOpenForThisPane = existing && existing.dataset.paneIndex === String(paneIndex);
document.querySelectorAll('#pane-tools-popup, #pane-favorites-popup, #pane-settings-popup, #pane-transfer-popup, #pane-upload-popup, #col-chooser-popover, .breadcrumb-popover').forEach(p => p.remove());
if (wasOpenForThisPane) return;
const pane = App.panes[paneIndex];
const currentName = pane?.customName || `${paneIndex + 1}`;
const colors = getPaneColors();
const currentColorConf = colors[paneIndex] || 'default';
const curBorderWidth = localStorage.getItem('cd_border_width') || '1px';
const curRingStyle = localStorage.getItem('cd_ring_style') || 'subtle';
const globalAngle = localStorage.getItem('cd_border_angle') || '45deg';
const resolved = resolvePaneColorConfig(currentColorConf, globalAngle);
const isPreset = typeof currentColorConf === 'string' && PANE_COLOR_PRESETS[currentColorConf];
const activePresetId = isPreset ? currentColorConf : null;
const customC1 = resolved.c1 || '#f59e0b';
const customC2 = resolved.c2 || '#f97316';
const customAngle = resolved.angle || globalAngle;
const popup = document.createElement('div');
popup.id = 'pane-settings-popup';
popup.dataset.paneIndex = String(paneIndex);
popup.className = 'pane-settings-dropdown active';
const visibleCount = Math.min(
App.panes.length,
App.layout === '1' ? 1 : (App.layout === '4' ? 4 : (App.layout.startsWith('3') ? 3 : 2))
);
const paneSwitchHtml = visibleCount > 1 ? `
<div style="display: flex; gap: 6px; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid var(--border);">
<span style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase;">Switch Active Pane</span>
<div style="display: flex; gap: 4px;">
${Array.from({ length: visibleCount }).map((_, pIdx) => `
<button type="button" class="btn btn-xs ${pIdx === paneIndex ? 'btn-accent' : 'btn-outline'}" style="padding: 2px 8px; font-size: 10px; font-weight: 700;" onclick="setActivePane(${pIdx}); document.getElementById('pane-settings-popup')?.remove();">
Pane ${pIdx + 1}
</button>
`).join('')}
</div>
</div>
` : '';
popup.innerHTML = `
<div style="padding: 8px 12px; font-weight: 700; font-size: 11px; color: var(--accent); background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<span style="display: flex; align-items: center; gap: 6px;">
<i data-lucide="sliders" style="width: 13px; height: 13px;"></i>
Pane ${paneIndex + 1} Settings
</span>
<span style="font-size: 11px; color: var(--text-dim); cursor: pointer;" onclick="document.getElementById('pane-settings-popup')?.remove();">✕</span>
</div>
<div class="pane-dropdown-body" style="padding: 10px 12px; display: flex; flex-direction: column; gap: 12px;">
${paneSwitchHtml}
<!-- 1. Renaming -->
<div>
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px;">Pane Name / Label</div>
<div style="display: flex; gap: 6px;">
<input type="text" id="pane-setting-name-input-${paneIndex}" value="${escapeHtml(currentName)}" placeholder="${paneIndex + 1}" style="flex: 1; height: 26px; padding: 0 8px; font-size: 11px; background: var(--bg-dark); border: 1px solid var(--border); border-radius: 4px; color: var(--text-main);" onkeydown="if(event.key==='Enter'){ applyPaneRenameFromInput(${paneIndex}); }">
<button type="button" class="btn btn-sm btn-accent" style="height: 26px; padding: 0 8px; font-size: 10px;" onclick="applyPaneRenameFromInput(${paneIndex})">Save</button>
<button type="button" class="btn btn-sm btn-outline" style="height: 26px; padding: 0 6px; font-size: 10px;" title="Reset to default ${paneIndex + 1}" onclick="resetPaneName(${paneIndex})">Reset</button>
</div>
</div>
<!-- 2. Border & Header Two-Tone Color Palette -->
<div style="border-top: 1px solid var(--border); padding-top: 10px;">
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px; display: flex; justify-content: space-between;">
<span>Two-Tone Color Presets</span>
<span style="color: var(--accent); cursor: pointer; text-transform: none; font-weight: 600;" onclick="cyclePaneColor(${paneIndex}); openPaneSettingsMenu(null, ${paneIndex});">Cycle ↻</span>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 8px;">
${Object.entries(PANE_COLOR_PRESETS).map(([pId, p]) => {
const isSelected = activePresetId === pId;
return `
<button type="button" class="color-swatch-btn ${isSelected ? 'active' : ''}"
style="display: flex; align-items: center; gap: 5px; padding: 4px 6px; background: var(--bg-panel); border: 1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'}; border-radius: 4px; cursor: pointer; color: var(--text-main); font-size: 10px; width: 100%; text-align: left;"
onclick="setPaneColorPref(${paneIndex}, '${pId}'); openPaneSettingsMenu(null, ${paneIndex});">
<span style="display: inline-block; width: 11px; height: 11px; border-radius: 50%; background: linear-gradient(135deg, ${p.c1}, ${p.c2}); border: 1px solid rgba(255,255,255,0.25); flex-shrink: 0; box-shadow: 0 0 3px rgba(0,0,0,0.4);"></span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${isSelected ? 'font-weight: 700; color: var(--accent);' : ''}">${p.name}</span>
</button>
`;
}).join('')}
</div>
<!-- Custom Dual-Tone Gradient -->
<div style="background: var(--bg-dark); border: 1px solid var(--border); border-radius: 6px; padding: 8px;">
<div style="font-size: 9.5px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center;">
<span>Custom Border Style</span>
<span style="font-size: 8.5px; color: var(--text-dim);">Color 1 → Color 2</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 6px;">
<!-- Color 1 -->
<div style="display: flex; align-items: center; gap: 4px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px;">
<input type="color" id="pane-custom-c1-${paneIndex}" value="${customC1}" style="width: 20px; height: 20px; border: none; padding: 0; background: transparent; cursor: pointer; border-radius: 3px;" oninput="updatePaneCustomGradientPreview(${paneIndex});">
<input type="text" id="pane-custom-t1-${paneIndex}" value="${customC1}" placeholder="#f59e0b" style="width: 100%; border: none; background: transparent; font-family: var(--font-mono); font-size: 10px; color: var(--text-main); outline: none;" oninput="document.getElementById('pane-custom-c1-${paneIndex}').value = this.value; updatePaneCustomGradientPreview(${paneIndex});">
</div>
<!-- Color 2 -->
<div style="display: flex; align-items: center; gap: 4px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px;">
<input type="color" id="pane-custom-c2-${paneIndex}" value="${customC2}" style="width: 20px; height: 20px; border: none; padding: 0; background: transparent; cursor: pointer; border-radius: 3px;" oninput="updatePaneCustomGradientPreview(${paneIndex});">
<input type="text" id="pane-custom-t2-${paneIndex}" value="${customC2}" placeholder="#f97316" style="width: 100%; border: none; background: transparent; font-family: var(--font-mono); font-size: 10px; color: var(--text-main); outline: none;" oninput="document.getElementById('pane-custom-c2-${paneIndex}').value = this.value; updatePaneCustomGradientPreview(${paneIndex});">
</div>
</div>
<!-- Angle Selection -->
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 6px;">
<span style="font-size: 9.5px; color: var(--text-muted); font-weight: 600;">Angle:</span>
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
${['45deg', '90deg', '135deg', '180deg', '270deg', '0deg'].map(ang => `
<button type="button" class="btn btn-xs ${customAngle === ang ? 'btn-accent' : 'btn-outline'}"
id="pane-ang-btn-${paneIndex}-${ang}"
style="padding: 1px 4px; font-size: 9px; min-width: 24px;"
onclick="setCustomAngleInput(${paneIndex}, '${ang}')">${ang.replace('deg', '°')}</button>
`).join('')}
</div>
<input type="hidden" id="pane-custom-angle-${paneIndex}" value="${customAngle}">
</div>
<!-- Preview & Apply button -->
<div style="display: flex; align-items: center; gap: 6px;">
<div id="pane-gradient-preview-${paneIndex}"
style="flex: 1; height: 24px; border-radius: 4px; background: linear-gradient(${customAngle}, ${customC1}, ${customC2}); border: 1px solid rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.8);">
Gradient Preview
</div>
<button type="button" class="btn btn-sm btn-accent" style="height: 24px; padding: 0 8px; font-size: 10px;" onclick="applyPaneCustomTwoTone(${paneIndex})">Apply</button>
</div>
</div>
</div>
${visibleCount > 1 ? `
<!-- 3. Rearrange Panel Position -->
<div style="border-top: 1px solid var(--border); padding-top: 10px;">
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center;">
<span>Rearrange Position</span>
<span style="font-size: 9px; color: var(--text-dim);">Drag badge or click</span>
</div>
<div style="display: flex; gap: 6px; margin-bottom: ${visibleCount > 2 ? '6px' : '0'};">
<button type="button" class="btn btn-xs btn-outline" style="flex: 1; padding: 4px 6px; font-size: 10px; display: flex; align-items: center; justify-content: center; gap: 4px; ${paneIndex === 0 ? 'opacity: 0.4; cursor: not-allowed;' : ''}"
${paneIndex === 0 ? 'disabled' : `onclick="swapPanes(${paneIndex}, ${paneIndex - 1}); openPaneSettingsMenu(null, ${paneIndex - 1});"`}>
<i data-lucide="arrow-left" style="width: 12px; height: 12px;"></i> Move Left
</button>
<button type="button" class="btn btn-xs btn-outline" style="flex: 1; padding: 4px 6px; font-size: 10px; display: flex; align-items: center; justify-content: center; gap: 4px; ${paneIndex >= visibleCount - 1 ? 'opacity: 0.4; cursor: not-allowed;' : ''}"
${paneIndex >= visibleCount - 1 ? 'disabled' : `onclick="swapPanes(${paneIndex}, ${paneIndex + 1}); openPaneSettingsMenu(null, ${paneIndex + 1});"`}>
Move Right <i data-lucide="arrow-right" style="width: 12px; height: 12px;"></i>
</button>
</div>
${visibleCount > 2 ? `
<div style="display: flex; align-items: center; gap: 4px;">
<span style="font-size: 9.5px; color: var(--text-dim);">Swap with:</span>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
${Array.from({ length: visibleCount }).map((_, pIdx) => {
if (pIdx === paneIndex) return '';
return `
<button type="button" class="btn btn-xs btn-outline" style="padding: 2px 6px; font-size: 9.5px;" onclick="swapPanes(${paneIndex}, ${pIdx}); openPaneSettingsMenu(null, ${pIdx});">
Pane ${pIdx + 1}
</button>
`;
}).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- 4. Global Border Width, Ring & Angle Settings -->
<div style="border-top: 1px solid var(--border); padding-top: 10px;">
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px;">Border Width</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-bottom: 8px;">
${['1px', '2px', '3px', '4px'].map(bw => `
<button type="button" class="btn btn-xs ${curBorderWidth === bw ? 'btn-accent' : 'btn-outline'}" style="padding: 2px 4px; font-size: 10px;" onclick="applyBorderSettings('${bw}', null, null); openPaneSettingsMenu(null, ${paneIndex});">${bw}</button>
`).join('')}
</div>
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px;">Active Ring Style</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-bottom: 8px;">
${[['subtle', 'Subtle'], ['bold', 'Bold'], ['glow', 'Glow'], ['none', 'None']].map(([rKey, rName]) => `
<button type="button" class="btn btn-xs ${curRingStyle === rKey ? 'btn-accent' : 'btn-outline'}" style="padding: 2px 4px; font-size: 9.5px;" onclick="applyBorderSettings(null, '${rKey}', null); openPaneSettingsMenu(null, ${paneIndex});">${rName}</button>
`).join('')}
</div>
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px;">Global Border Angle</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px;">
${['45deg', '90deg', '135deg', '180deg', '270deg', '0deg'].map(ang => `
<button type="button" class="btn btn-xs ${globalAngle === ang ? 'btn-accent' : 'btn-outline'}" style="padding: 2px 2px; font-size: 9px;" onclick="applyBorderSettings(null, null, '${ang}'); openPaneSettingsMenu(null, ${paneIndex});">${ang.replace('deg', '°')}</button>
`).join('')}
</div>
</div>
<!-- 5. Default Start Directory -->
<div style="border-top: 1px solid var(--border); padding-top: 10px;">
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px;">Start Directory</div>
<div style="display: flex; gap: 6px; align-items: center;">
<input type="text" id="pane-setting-path-input-${paneIndex}" value="${escapeHtml(pane?.path || '/')}" placeholder="/" style="flex: 1; height: 26px; padding: 0 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-dark); border: 1px solid var(--border); border-radius: 4px; color: var(--text-main);" onkeydown="if(event.key==='Enter'){ applyPaneStartPath(${paneIndex}); }">
<button type="button" class="btn btn-sm btn-accent" style="height: 26px; padding: 0 8px; font-size: 10px;" title="Set as default start path for Pane ${paneIndex + 1}" onclick="applyPaneStartPath(${paneIndex})">Set Start</button>
</div>
</div>
<!-- 6. Pane Synchronization -->
<div style="border-top: 1px solid var(--border); padding-top: 10px;">
<div style="font-size: 10px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center;">
<span>Pane Synchronization</span>
<span style="font-size: 9px; color: var(--text-dim); text-transform: none;">Pane ${paneIndex + 1} Participation</span>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 10.5px; cursor: pointer; color: var(--text-main);">
<input type="checkbox" id="pane-setting-sync-scroll-${paneIndex}" ${pane?.syncScroll !== false ? 'checked' : ''} onchange="togglePaneSyncScroll(${paneIndex}, this.checked)">
<span>Include Pane ${paneIndex + 1} in Scroll Sync</span>
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 10.5px; cursor: pointer; color: var(--text-main);">
<input type="checkbox" id="pane-setting-sync-nav-${paneIndex}" ${pane?.syncNav !== false ? 'checked' : ''} onchange="togglePaneSyncNav(${paneIndex}, this.checked)">
<span>Include Pane ${paneIndex + 1} in Navigation Sync</span>
</label>
</div>
</div>
<!-- 7. Cloud Workspace Persistence (Web/Server Mode) -->
<div class="web-only-setting server-only-setting" style="border-top: 1px solid var(--border); padding-top: 10px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-dim);"><i data-lucide="cloud" style="width: 11px; height: 11px; vertical-align: -1px;"></i> Cross-Device Sync</span>
<button type="button" class="btn btn-xs btn-outline" style="font-size: 9.5px; padding: 2px 6px; display: flex; align-items: center; gap: 4px;" onclick="saveCurrentWorkspaceAsDefault(); openPaneSettingsMenu(null, ${paneIndex});">
Save All to Cloud
</button>
</div>
</div>
`;
const btn = document.getElementById(`pane-badge-btn-${paneIndex}`);
if (btn) {
const rect = btn.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = `${rect.bottom + 4}px`;
popup.style.left = `${Math.max(10, Math.min(rect.left, window.innerWidth - 330))}px`;
} else if (e && e.target) {
const rect = e.target.getBoundingClientRect();
popup.style.position = 'fixed';
popup.style.top = `${rect.bottom + 4}px`;
popup.style.left = `${Math.max(10, Math.min(rect.left, window.innerWidth - 330))}px`;
}
document.body.appendChild(popup);
if (window.lucide) lucide.createIcons();
const inputEl = document.getElementById(`pane-setting-name-input-${paneIndex}`);
if (inputEl) {
setTimeout(() => { inputEl.focus(); inputEl.select(); }, 50);
}
const closeHandler = (evt) => {
if (!popup.contains(evt.target) && !btn?.contains(evt.target) && !evt.target.closest(`.mobile-pane-tab[data-pane-idx="${paneIndex}"]`)) {
popup.remove();
document.removeEventListener('click', closeHandler);
document.removeEventListener('touchstart', closeHandler);
document.removeEventListener('contextmenu', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
document.addEventListener('touchstart', closeHandler);
document.addEventListener('contextmenu', closeHandler);
}, 10);
}
function applyPaneRenameFromInput(paneIndex) {
const input = document.getElementById(`pane-setting-name-input-${paneIndex}`);
if (!input) return;
const newName = input.value.trim();
const pane = App.panes[paneIndex];
if (!pane) return;
pane.customName = newName && newName !== `${paneIndex + 1}` ? newName : null;
savePaneCustomNames();
updatePaneTitles();
document.getElementById('pane-settings-popup')?.remove();
showToast(pane.customName ? `Pane ${paneIndex + 1} renamed to "${pane.customName}"` : `Pane ${paneIndex + 1} name reset to default`, 'info');
}
function resetPaneName(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
pane.customName = null;
savePaneCustomNames();
updatePaneTitles();
document.getElementById('pane-settings-popup')?.remove();
showToast(`Pane ${paneIndex + 1} name reset to "${paneIndex + 1}"`, 'info');
}
function applyPaneStartPath(paneIndex) {
const input = document.getElementById(`pane-setting-path-input-${paneIndex}`);
if (!input) return;
const newPath = input.value.trim() || '/';
const pane = App.panes[paneIndex];
if (pane) {
pane.path = newPath;
pane.history = [newPath];
pane.historyIndex = 0;
localStorage.setItem(`cd_pane_path_${paneIndex}`, newPath);
}
queueSaveUserPreferencesToServer();
document.getElementById('pane-settings-popup')?.remove();
renderPane(paneIndex);
showToast(`Pane ${paneIndex + 1} start directory set to: ${newPath}`, 'success');
}
function openPaneColorPicker(e, paneIndex) {
openPaneSettingsMenu(e, paneIndex);
}
function updatePaneCustomGradientPreview(paneIndex) {
const c1Input = document.getElementById(`pane-custom-c1-${paneIndex}`);
const c2Input = document.getElementById(`pane-custom-c2-${paneIndex}`);
const t1Input = document.getElementById(`pane-custom-t1-${paneIndex}`);
const t2Input = document.getElementById(`pane-custom-t2-${paneIndex}`);
const angInput = document.getElementById(`pane-custom-angle-${paneIndex}`);
const preview = document.getElementById(`pane-gradient-preview-${paneIndex}`);
if (!preview) return;
const c1 = c1Input?.value || '#f59e0b';
const c2 = c2Input?.value || '#f97316';
const ang = angInput?.value || '45deg';
if (t1Input && document.activeElement !== t1Input) t1Input.value = c1;
if (t2Input && document.activeElement !== t2Input) t2Input.value = c2;
preview.style.background = `linear-gradient(${ang}, ${c1}, ${c2})`;
}
function setCustomAngleInput(paneIndex, ang) {
const angInput = document.getElementById(`pane-custom-angle-${paneIndex}`);
if (angInput) angInput.value = ang;
['45deg', '90deg', '135deg', '180deg', '270deg', '0deg'].forEach(a => {
const btn = document.getElementById(`pane-ang-btn-${paneIndex}-${a}`);
if (btn) {
if (a === ang) {
btn.classList.add('btn-accent');
btn.classList.remove('btn-outline');
} else {
btn.classList.remove('btn-accent');
btn.classList.add('btn-outline');
}
}
});
updatePaneCustomGradientPreview(paneIndex);
}
function applyPaneCustomTwoTone(paneIndex) {
const c1 = document.getElementById(`pane-custom-c1-${paneIndex}`)?.value || '#f59e0b';
const c2 = document.getElementById(`pane-custom-c2-${paneIndex}`)?.value || '#f97316';
const ang = document.getElementById(`pane-custom-angle-${paneIndex}`)?.value || '45deg';
setPaneColorPref(paneIndex, { c1, c2, angle: ang });
openPaneSettingsMenu(null, paneIndex);
}
function applyPaneColors() {
const colors = getPaneColors();
const globalAngle = localStorage.getItem('cd_border_angle') || '45deg';
for (let i = 0; i < 4; i++) {
const paneEl = document.getElementById(`pane-${i}`);
const colorConf = colors[i] || 'default';
const resolved = resolvePaneColorConfig(colorConf, globalAngle);
if (paneEl) {
PANE_COLOR_PALETTE.forEach(c => {
if (c !== 'default') paneEl.classList.remove(`pane-color-${c}`);
});
paneEl.style.setProperty('--pane-border-angle', resolved.angle);
paneEl.style.setProperty('--pane-border-c1', resolved.inactiveC1);
paneEl.style.setProperty('--pane-border-c2', resolved.inactiveC2);
paneEl.style.setProperty('--pane-active-c1', resolved.activeC1);
paneEl.style.setProperty('--pane-active-c2', resolved.activeC2);
paneEl.style.setProperty('--pane-glow', resolved.glow);
if (resolved.isCustom) {
paneEl.classList.add('pane-color-custom');
} else {
paneEl.classList.remove('pane-color-custom');
if (resolved.id !== 'default') {
paneEl.classList.add(`pane-color-${resolved.id}`);
}
}
}
const badgeBtn = document.getElementById(`pane-badge-btn-${i}`);
const badgeInd = document.getElementById(`pane-badge-indicator-${i}`);
if (badgeInd) {
badgeInd.style.background = `linear-gradient(${resolved.angle}, ${resolved.activeC1}, ${resolved.activeC2})`;
}
if (badgeBtn) {
PANE_COLOR_PALETTE.forEach(c => {
if (c !== 'default') badgeBtn.classList.remove(`pane-color-${c}`);
});
if (resolved.isCustom) {
badgeBtn.style.borderColor = resolved.inactiveC1;
badgeBtn.style.color = resolved.activeC1;
badgeBtn.style.background = resolved.badgeBg;
badgeBtn.classList.add('pane-color-custom');
} else {
badgeBtn.style.borderColor = '';
badgeBtn.style.color = '';
badgeBtn.style.background = '';
badgeBtn.classList.remove('pane-color-custom');
if (resolved.id !== 'default') {
badgeBtn.classList.add(`pane-color-${resolved.id}`);
}
}
}
const ghostEl = document.getElementById(`pane-ghost-${i}`);
if (ghostEl) {
if (resolved.id !== 'default') {
ghostEl.style.color = resolved.activeC1;
} else {
ghostEl.style.color = 'var(--accent)';
}
}
const selectEl = document.getElementById(`setting-pane-color-${i}`);
if (selectEl) {
selectEl.value = (typeof colorConf === 'string' && PANE_COLOR_PALETTE.includes(colorConf)) ? colorConf : 'default';
}
}
}
function navPaneUp(index) {
const pane = App.panes[index];
if (!pane) return;
if (pane.isBranchView) {
pane.isBranchView = false;
pane.isBranchTruncated = false;
}
if (pane.path === '/' || pane.path === '') return;
if (pane.parentPath && pane.parentPath !== pane.path) {
loadPaneDirectory(index, pane.parentPath);
return;
}
const parent = getParentDirectory(pane.path);
if (parent && parent !== pane.path) {
loadPaneDirectory(index, parent);
}
}
function refreshPane(index, selectItemName = null) {
if (typeof index !== 'number' || index < 0 || !App.panes || !App.panes[index]) return;
const btn = document.getElementById(`btn-refresh-pane-${index}`);
if (btn) {
btn.classList.add('refreshing');
setTimeout(() => btn.classList.remove('refreshing'), 600);
}
const pane = App.panes[index];
if (pane && pane.path) {
invalidateDirectoryCache(pane.nodeId, pane.path);
}
loadPaneDirectory(index, App.panes[index].path, false, selectItemName, true);
}
function enablePathInput(index) {
const crumbs = document.getElementById(`pane-crumbs-${index}`);
const input = document.getElementById(`pane-input-${index}`);
crumbs.style.display = 'none';
input.style.display = 'block';
input.value = sanitizeCredentials(App.panes[index].path);
input.focus();
}
function disablePathInput(index) {
const crumbs = document.getElementById(`pane-crumbs-${index}`);
const input = document.getElementById(`pane-input-${index}`);
crumbs.style.display = 'flex';
input.style.display = 'none';
}
function handlePathKey(e, index) {
if (e.key === 'Enter') {
let target = e.target.value.trim();
if (target.includes(':***@')) {
const current = App.panes[index].path;
const curAuthMatch = current.match(/:\/\/([^@\s/]+)@/);
if (curAuthMatch) {
target = target.replace(/:\/\/[^@\s/]+@/, `://${curAuthMatch[1]}@`);
}
}
loadPaneDirectory(index, target);
disablePathInput(index);
} else if (e.key === 'Escape') {
disablePathInput(index);
}
}
function togglePaneFilter(index) {
const pane = App.panes[index];
if (!pane) return;
pane.showFilter = !pane.showFilter;
const wrap = document.getElementById(`pane-filter-wrap-${index}`);
const btn = document.getElementById(`btn-filter-toggle-${index}`);
const input = document.getElementById(`pane-filter-${index}`);
if (wrap) {
wrap.style.display = pane.showFilter ? 'flex' : 'none';
}
if (btn) {
if (pane.showFilter) btn.classList.add('active');
else btn.classList.remove('active');
}
if (pane.showFilter) {
if (input) {
input.focus();
input.select();
}
} else {
if (pane.filterText) {
pane.filterText = '';
if (input) input.value = '';
renderPaneTable(index);
}
}
}
function clearPaneFilter(index) {
const pane = App.panes[index];
if (!pane) return;
pane.filterText = '';
const input = document.getElementById(`pane-filter-${index}`);
if (input) {
input.value = '';
input.focus();
}
renderPaneTable(index);
}
function handleFilterKey(e, index) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
togglePaneFilter(index);
}
}
function handleFilterInput(index, val) {
App.panes[index].filterText = val;
renderPaneTable(index);
}
function sortPane(index, sortBy) {
const pane = App.panes[index];
if (pane.sortBy === sortBy) pane.sortAsc = !pane.sortAsc;
else {
pane.sortBy = sortBy;
pane.sortAsc = true;
}
renderPaneTable(index);
}
function showModal(id) {
hideContextMenu();
const el = document.getElementById(id);
if (el) {
if (id === 'login-modal') {
randomizeLoginBackground();
}
el.classList.add('active');
el.style.display = 'flex';
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: el });
}
} catch (_) {
try { lucide.createIcons(); } catch (__) {}
}
if (typeof window !== 'undefined' && window.history && typeof window.history.pushState === 'function') {
try {
window.history.pushState({ type: 'modal', modalId: id }, '', '');
} catch (_) {}
}
}
}
const openModal = showModal;
function hideModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.remove('active');
el.style.display = 'none';
}
}
function closeModal(id) {
if (id === 'converter-modal' || id === 'floating-converter-window') {
closeConverterModal();
return;
}
if (id === 'file-splitter-modal' || id === 'floating-splitter-window') {
closeFloatingSplitter();
return;
}
if (id === 'pdf-tool-modal' || id === 'floating-pdf-window') {
closeFloatingPdf();
return;
}
if (id === 'shares-manager-modal' || id === 'floating-shares-window') {
closeFloatingShares();
return;
}
if (id === 'disk-usage-modal' || id === 'floating-diskusage-window') {
closeFloatingDiskUsage();
return;
}
if (id) {
hideModal(id);
} else {
document.querySelectorAll('.modal-overlay').forEach(m => {
m.classList.remove('active');
m.style.display = 'none';
});
}
}
// ---------------- UNIVERSAL CUSTOM DIALOG & TOAST SYSTEM ----------------
let currentDialogResolver = null;
function showConfirmDialog({
title = 'Confirmation',
subtitle = 'Please confirm your action',
message = 'Are you sure you want to proceed?',
items = [],
icon = 'help-circle',
type = 'warning', // 'warning', 'danger', 'info', 'success'
confirmText = 'Confirm',
cancelText = 'Cancel',
} = {}) {
return new Promise((resolve) => {
currentDialogResolver = resolve;
const titleEl = document.getElementById('dialog-title');
const subEl = document.getElementById('dialog-subtitle');
const msgEl = document.getElementById('dialog-message');
if (titleEl) titleEl.textContent = title;
if (subEl) subEl.textContent = subtitle;
if (msgEl) {
msgEl.style.whiteSpace = 'pre-line';
msgEl.textContent = message;
}
const iconWrap = document.getElementById('dialog-icon-wrapper');
if (iconWrap) iconWrap.className = `dialog-icon-wrapper ${type}`;
const iconEl = document.getElementById('dialog-icon');
if (iconEl) iconEl.setAttribute('data-lucide', icon);
const itemsBox = document.getElementById('dialog-items-preview');
if (itemsBox) {
if (items && items.length > 0) {
itemsBox.style.display = 'flex';
itemsBox.innerHTML = items.map(it => `<div>• ${escapeHtml(it)}</div>`).join('');
} else {
itemsBox.style.display = 'none';
}
}
const inputGroup = document.getElementById('dialog-input-group');
if (inputGroup) inputGroup.style.display = 'none';
const btnCancel = document.getElementById('dialog-btn-cancel');
if (btnCancel) {
btnCancel.textContent = cancelText;
btnCancel.style.display = 'inline-block';
}
const btnConfirm = document.getElementById('dialog-btn-confirm');
if (btnConfirm) {
btnConfirm.textContent = confirmText;
btnConfirm.className = `btn ${type === 'danger' ? 'btn-danger' : (type === 'success' ? 'btn-success' : 'btn-accent')}`;
}
showModal('app-dialog-modal');
if (window.lucide) lucide.createIcons();
setTimeout(() => btnConfirm?.focus(), 50);
});
}
function showAlertDialog({
title = 'Notice',
subtitle = 'Information',
message = '',
icon = 'info',
type = 'info',
okText = 'OK',
} = {}) {
return new Promise((resolve) => {
currentDialogResolver = resolve;
const titleEl = document.getElementById('dialog-title');
const subEl = document.getElementById('dialog-subtitle');
const msgEl = document.getElementById('dialog-message');
if (titleEl) titleEl.textContent = title;
if (subEl) subEl.textContent = subtitle;
if (msgEl) {
msgEl.style.whiteSpace = 'pre-line';
msgEl.textContent = message;
}
const iconWrap = document.getElementById('dialog-icon-wrapper');
if (iconWrap) iconWrap.className = `dialog-icon-wrapper ${type}`;
const iconEl = document.getElementById('dialog-icon');
if (iconEl) iconEl.setAttribute('data-lucide', icon);
const itemsBox = document.getElementById('dialog-items-preview');
if (itemsBox) itemsBox.style.display = 'none';
const inputGroup = document.getElementById('dialog-input-group');
if (inputGroup) inputGroup.style.display = 'none';
const btnCancel = document.getElementById('dialog-btn-cancel');
if (btnCancel) btnCancel.style.display = 'none';
const btnConfirm = document.getElementById('dialog-btn-confirm');
if (btnConfirm) {
btnConfirm.textContent = okText;
btnConfirm.className = `btn ${type === 'danger' ? 'btn-danger' : 'btn-accent'}`;
}
showModal('app-dialog-modal');
if (window.lucide) lucide.createIcons();
setTimeout(() => btnConfirm?.focus(), 50);
});
}
function showPromptDialog({
title = 'Input Required',
subtitle = 'Enter value',
message = 'Please enter a value:',
defaultValue = '',
placeholder = '',
inputType = 'text',
confirmText = 'OK',
cancelText = 'Cancel',
} = {}) {
return new Promise((resolve) => {
currentDialogResolver = resolve;
const titleEl = document.getElementById('dialog-title');
const subEl = document.getElementById('dialog-subtitle');
const msgEl = document.getElementById('dialog-message');
if (titleEl) titleEl.textContent = title;
if (subEl) subEl.textContent = subtitle;
if (msgEl) {
msgEl.style.whiteSpace = 'pre-line';
msgEl.textContent = message;
}
const iconWrap = document.getElementById('dialog-icon-wrapper');
if (iconWrap) iconWrap.className = 'dialog-icon-wrapper info';
const iconEl = document.getElementById('dialog-icon');
if (iconEl) iconEl.setAttribute('data-lucide', 'edit-3');
const itemsBox = document.getElementById('dialog-items-preview');
if (itemsBox) itemsBox.style.display = 'none';
const inputGroup = document.getElementById('dialog-input-group');
const inputField = document.getElementById('dialog-input-field');
if (inputGroup) inputGroup.style.display = 'block';
if (inputField) {
inputField.type = inputType || 'text';
inputField.value = defaultValue;
inputField.placeholder = placeholder;
inputField.onkeydown = (e) => {
if (e.key === 'Enter') closeAppDialog(inputField.value);
else if (e.key === 'Escape') closeAppDialog(null);
};
}
const btnCancel = document.getElementById('dialog-btn-cancel');
if (btnCancel) {
btnCancel.textContent = cancelText;
btnCancel.style.display = 'inline-block';
}
const btnConfirm = document.getElementById('dialog-btn-confirm');
if (btnConfirm) {
btnConfirm.textContent = confirmText;
btnConfirm.className = 'btn btn-accent';
}
showModal('app-dialog-modal');
if (window.lucide) lucide.createIcons();
setTimeout(() => {
inputField?.focus();
inputField?.select();
}, 50);
});
}
function closeAppDialog(result) {
closeModal('app-dialog-modal');
if (currentDialogResolver) {
if (document.getElementById('dialog-input-group')?.style.display !== 'none' && result === true) {
result = document.getElementById('dialog-input-field')?.value;
}
const resolver = currentDialogResolver;
currentDialogResolver = null;
resolver(result);
}
}
function showToast(message, type = 'info', duration = 3500) {
const container = document.getElementById('toast-container');
if (!container) return;
const safeMsg = sanitizeCredentials(message);
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.style.setProperty('--toast-duration', `${duration / 1000}s`);
let iconName = 'info';
if (type === 'success') iconName = 'check-circle';
else if (type === 'error') iconName = 'alert-triangle';
else if (type === 'warning') iconName = 'alert-circle';
toast.innerHTML = `
<i data-lucide="${iconName}" style="width: 16px; height: 16px; flex-shrink: 0;"></i>
<span>${escapeHtml(safeMsg)}</span>
`;
container.appendChild(toast);
if (window.lucide) lucide.createIcons({ root: toast });
setTimeout(() => {
toast.remove();
}, duration + 300);
}
function setupHistoryNavigation() {
if (typeof window === 'undefined' || !window.history || typeof window.history.pushState !== 'function') return;
// Set initial state
try {
window.history.replaceState({ type: 'dir', paneIndex: App.activePaneIndex, path: App.panes[App.activePaneIndex]?.path || '/' }, '', '');
} catch (_) {}
window.addEventListener('popstate', (e) => {
// 1. If any modal is active, close it!
const activeModals = Array.from(document.querySelectorAll('.modal-overlay.active'));
if (activeModals.length > 0) {
const topModal = activeModals[activeModals.length - 1];
if (topModal.id === 'media-player-modal') {
closeMediaPlayer();
} else {
topModal.classList.remove('active');
}
try {
window.history.pushState({ type: 'dir', paneIndex: App.activePaneIndex, path: App.panes[App.activePaneIndex]?.path || '/' }, '', '');
} catch (_) {}
return;
}
// 2. If terminal drawer is open, close it!
const termDrawer = document.getElementById('terminal-drawer');
if (termDrawer && termDrawer.classList.contains('active')) {
toggleTerminal(false);
try {
window.history.pushState({ type: 'dir', paneIndex: App.activePaneIndex, path: App.panes[App.activePaneIndex]?.path || '/' }, '', '');
} catch (_) {}
return;
}
// 4. If floating task manager is open, minimize/close it!
const taskWin = document.getElementById('floating-task-manager');
if (taskWin && taskWin.classList.contains('active') && !taskWin.classList.contains('minimized')) {
minimizeFloatingTaskManager();
try {
window.history.pushState({ type: 'dir', paneIndex: App.activePaneIndex, path: App.panes[App.activePaneIndex]?.path || '/' }, '', '');
} catch (_) {}
return;
}
// 4. Directory Navigation
if (e.state && e.state.type === 'dir' && e.state.path) {
loadPaneDirectory(e.state.paneIndex ?? App.activePaneIndex, e.state.path, false);
} else {
const pane = App.panes[App.activePaneIndex];
if (pane && pane.path !== '/' && pane.path !== '') {
navPaneUp(App.activePaneIndex);
}
try {
window.history.pushState({ type: 'dir', paneIndex: App.activePaneIndex, path: pane?.path || '/' }, '', '');
} catch (_) {}
}
});
}
function formatDate(timestampSec, compact = undefined) {
if (!timestampSec) return '-';
const d = new Date(timestampSec * 1000);
if (isNaN(d.getTime())) return '-';
const isCompact = (typeof compact === 'boolean') ? compact : (localStorage.getItem('cd_compact_dates') !== '0');
const pad = n => String(n).padStart(2, '0');
const now = new Date();
const year = d.getFullYear();
const month = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const hours = pad(d.getHours());
const mins = pad(d.getMinutes());
if (isCompact) {
if (year === now.getFullYear()) {
return `${month}-${day} ${hours}:${mins}`;
}
return `${year}-${month}-${day}`;
}
return `${year}-${month}-${day} ${hours}:${mins}`;
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function escapeJsStr(str) {
if (str === null || str === undefined) return "''";
return JSON.stringify(String(str)).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function toggleFKeyBar(show) {
App.showFKeyBar = !!show;
localStorage.setItem('cd_show_fkeys', show ? 'true' : 'false');
applyFKeyBarState();
queueSaveUserPreferencesToServer();
showToast(show ? 'Bottom function key bar enabled' : 'Bottom function key bar hidden', 'info');
}
function toggleWindowDecorations(show) {
App.windowDecorations = show;
localStorage.setItem('cd_window_decorations', show);
const cb = document.getElementById('setting-window-decorations');
if (cb) cb.checked = show;
showToast(show ? 'Window titlebar & frame enabled (restart to apply)' : 'Window decorations disabled (Borderless/Tiling mode for Hyprland)', 'info');
}
function toggleMediaStopOnClose(stopOnClose) {
App.mediaStopOnClose = !!stopOnClose;
localStorage.setItem('cd_media_stop_on_close', stopOnClose ? 'true' : 'false');
queueSaveUserPreferencesToServer();
showToast(stopOnClose ? 'Media Player: Stop playback on close' : 'Media Player: Keep playing in background on close', 'info');
}
function updateMobileBottomBarMode(val) {
if (!['icons_text', 'icons_only', 'off'].includes(val)) {
val = 'icons_text';
}
App.mobileBottomBarMode = val;
localStorage.setItem('cd_mobile_bottom_bar_mode', val);
applyMobileBottomBarMode(val);
queueSaveUserPreferencesToServer();
const labelMap = {
'icons_text': 'Icons and Text',
'icons_only': 'Icons Only',
'off': 'Off (Hidden)'
};
showToast(`Mobile bottom bar: ${labelMap[val] || val}`, 'info');
}
function applyMobileBottomBarMode(mode) {
const val = mode || App.mobileBottomBarMode || localStorage.getItem('cd_mobile_bottom_bar_mode') || 'icons_text';
App.mobileBottomBarMode = val;
document.documentElement.setAttribute('data-mobile-bar', val);
const bar = document.getElementById('mobile-bottom-bar');
if (bar) {
bar.classList.remove('mode-icons_text', 'mode-icons_only', 'mode-off');
bar.classList.add(`mode-${val}`);
if (val === 'off') {
bar.style.display = 'none';
} else {
bar.style.display = '';
}
}
const sel = document.getElementById('setting-mobile-bottom-bar-mode');
if (sel && sel.value !== val) {
sel.value = val;
}
}
// ---------------- PANE SYNCHRONIZATION (SCROLLING & NAVIGATION) ----------------
let isSyncScrolling = false;
let isSyncNavigating = false;
function toggleSyncScroll(forceState) {
const next = (typeof forceState === 'boolean') ? forceState : !App.syncScroll;
App.syncScroll = next;
localStorage.setItem('cd_sync_scroll', next ? 'true' : 'false');
applySyncScrollState();
queueSaveUserPreferencesToServer();
showToast(`Synchronized scrolling: ${next ? 'Enabled' : 'Disabled'}`, 'info');
if (next) {
const activePane = App.panes[App.activePaneIndex];
if (!activePane || activePane.syncScroll !== false) {
const activeMain = document.getElementById(`pane-main-${App.activePaneIndex}`);
if (activeMain) handlePaneScrollSync(App.activePaneIndex, activeMain);
}
}
}
function toggleSyncNav(forceState) {
const next = (typeof forceState === 'boolean') ? forceState : !App.syncNav;
App.syncNav = next;
localStorage.setItem('cd_sync_nav', next ? 'true' : 'false');
applySyncNavState();
queueSaveUserPreferencesToServer();
showToast(`Synchronized navigation: ${next ? 'Enabled' : 'Disabled'}`, 'info');
}
function togglePaneSyncScroll(paneIndex, forceState) {
const pane = App.panes[paneIndex];
if (!pane) return;
const next = (typeof forceState === 'boolean') ? forceState : !(pane.syncScroll !== false);
pane.syncScroll = next;
localStorage.setItem(`cd_pane_sync_scroll_${paneIndex}`, next ? 'true' : 'false');
queueSaveUserPreferencesToServer();
showToast(`Pane ${paneIndex + 1} scroll sync: ${next ? 'Included' : 'Excluded'}`, 'info');
}
function togglePaneSyncNav(paneIndex, forceState) {
const pane = App.panes[paneIndex];
if (!pane) return;
const next = (typeof forceState === 'boolean') ? forceState : !(pane.syncNav !== false);
pane.syncNav = next;
localStorage.setItem(`cd_pane_sync_nav_${paneIndex}`, next ? 'true' : 'false');
queueSaveUserPreferencesToServer();
showToast(`Pane ${paneIndex + 1} navigation sync: ${next ? 'Included' : 'Excluded'}`, 'info');
}
function applySyncScrollState() {
const isEnabled = App.syncScroll === true || localStorage.getItem('cd_sync_scroll') === 'true';
App.syncScroll = isEnabled;
const btn = document.getElementById('btn-sync-scroll');
if (btn) btn.classList.toggle('active', isEnabled);
const cb = document.getElementById('setting-sync-scroll');
if (cb && cb.checked !== isEnabled) cb.checked = isEnabled;
}
function applySyncNavState() {
const isEnabled = App.syncNav === true || localStorage.getItem('cd_sync_nav') === 'true';
App.syncNav = isEnabled;
const btn = document.getElementById('btn-sync-nav');
if (btn) btn.classList.toggle('active', isEnabled);
const cb = document.getElementById('setting-sync-nav');
if (cb && cb.checked !== isEnabled) cb.checked = isEnabled;
}
function handlePaneScrollSync(sourceIndex, sourceMainView) {
if (!App.syncScroll || isSyncScrolling || !sourceMainView) return;
const sourcePane = App.panes[sourceIndex];
if (sourcePane && sourcePane.syncScroll === false) return;
const visibleCount = getVisiblePaneCount();
if (visibleCount <= 1) return;
isSyncScrolling = true;
try {
const targetTop = sourceMainView.scrollTop;
const targetLeft = sourceMainView.scrollLeft;
for (let i = 0; i < visibleCount; i++) {
if (i === sourceIndex) continue;
const targetPane = App.panes[i];
if (targetPane && targetPane.syncScroll === false) continue;
const targetMain = document.getElementById(`pane-main-${i}`);
if (targetMain && !targetPane?.dockedTool) {
if (targetMain.scrollTop !== targetTop) {
targetMain.scrollTop = targetTop;
}
if (targetMain.scrollLeft !== targetLeft) {
targetMain.scrollLeft = targetLeft;
}
}
}
} finally {
requestAnimationFrame(() => {
isSyncScrolling = false;
});
}
}
function normalizeSyncPath(p) {
if (!p) return '';
let s = sanitizeCredentials(p).trim();
if (!s.startsWith('client://') && !s.includes('://')) {
s = s.replace(/\\/g, '/');
}
if (s.length > 1 && s.endsWith('/')) {
s = s.replace(/\/+$/, '');
}
return s;
}
function computeSyncedTargetPath(prevPath, newPath, otherPath) {
const cleanPrev = normalizeSyncPath(prevPath);
const cleanNew = normalizeSyncPath(newPath);
const otherClean = normalizeSyncPath(otherPath);
if (!cleanPrev || !cleanNew || !otherClean || cleanPrev === cleanNew) return null;
// Case 0: If other pane was at the exact same directory, mirror directly
if (otherClean === cleanPrev) {
return cleanNew;
}
// Case 1: Descendant navigation (subfolder entered)
if (cleanNew.startsWith(cleanPrev + '/')) {
const relSub = cleanNew.substring(cleanPrev.length + 1);
return otherClean === '/' ? `/${relSub}` : `${otherClean}/${relSub}`;
}
// Case 2: Ancestor navigation (moved up one or more parent levels)
if (cleanPrev.startsWith(cleanNew + '/')) {
const prevSegs = cleanPrev.split('/').filter(Boolean);
const newSegs = cleanNew.split('/').filter(Boolean);
const levelsUp = Math.max(1, prevSegs.length - newSegs.length);
let curr = otherClean;
for (let l = 0; l < levelsUp; l++) {
const parent = getParentDirectory(curr);
if (parent && parent !== curr) {
curr = parent;
} else {
break;
}
}
return curr;
}
// Case 3: Sibling or branched navigation under a common ancestor
const prevSegs = cleanPrev.split('/').filter(Boolean);
const newSegs = cleanNew.split('/').filter(Boolean);
let commonLen = 0;
while (commonLen < prevSegs.length && commonLen < newSegs.length && prevSegs[commonLen] === newSegs[commonLen]) {
commonLen++;
}
if (commonLen > 0) {
const levelsUp = prevSegs.length - commonLen;
const remainingNew = newSegs.slice(commonLen).join('/');
let curr = otherClean;
for (let l = 0; l < levelsUp; l++) {
const parent = getParentDirectory(curr);
if (parent && parent !== curr) {
curr = parent;
} else {
break;
}
}
if (remainingNew) {
return curr === '/' ? `/${remainingNew}` : `${curr}/${remainingNew}`;
}
return curr;
}
return null;
}
async function handlePaneNavSync(sourceIndex, prevPath, newPath) {
if (!App.syncNav || isSyncNavigating) return;
if (!prevPath || !newPath || prevPath === newPath) return;
const sourcePane = App.panes[sourceIndex];
if (sourcePane && sourcePane.syncNav === false) return;
const visibleCount = getVisiblePaneCount();
if (visibleCount <= 1) return;
isSyncNavigating = true;
try {
const promises = [];
for (let i = 0; i < visibleCount; i++) {
if (i === sourceIndex) continue;
const otherPane = App.panes[i];
if (!otherPane || !otherPane.path || otherPane.dockedTool) continue;
if (otherPane.syncNav === false) continue;
const otherTarget = computeSyncedTargetPath(prevPath, newPath, otherPane.path);
if (otherTarget && otherTarget !== otherPane.path) {
if (typeof otherTarget === 'string' && otherTarget.startsWith('client://')) {
promises.push(loadClientLocalDirectory(i, otherTarget, false, null, false, true));
} else {
promises.push(loadPaneDirectory(i, otherTarget, false, null, false, true));
}
}
}
if (promises.length > 0) {
await Promise.allSettled(promises);
}
} catch (syncErr) {
console.warn('Pane sync navigation error:', syncErr);
} finally {
requestAnimationFrame(() => {
isSyncNavigating = false;
});
}
}
function applyFKeyBarState() {
const isEnabled = App.showFKeyBar !== false && localStorage.getItem('cd_show_fkeys') !== 'false';
App.showFKeyBar = isEnabled;
const bar = document.querySelector('.bottom-fkey-bar');
if (bar) {
if (isEnabled) {
bar.classList.remove('fkey-hidden');
document.documentElement.classList.remove('fkeys-disabled');
} else {
bar.classList.add('fkey-hidden');
document.documentElement.classList.add('fkeys-disabled');
}
}
const checkbox = document.getElementById('setting-show-fkeys');
if (checkbox) checkbox.checked = isEnabled;
}
function handleFontSizeChange(val) {
App.fontSize = parseInt(val, 10);
localStorage.setItem('cd_font_size', App.fontSize);
applyFontSize(App.fontSize);
queueSaveUserPreferencesToServer();
}
function setFontSizePreset(val) {
const slider = document.getElementById('setting-font-size-slider');
if (slider) slider.value = val;
handleFontSizeChange(val);
}
function applyFontSize(val) {
document.documentElement.style.setProperty('--base-font-size', `${val}px`);
document.body.style.fontSize = `${val}px`;
const badge = document.getElementById('setting-font-size-val');
if (badge) badge.textContent = `${val}px`;
const slider = document.getElementById('setting-font-size-slider');
if (slider) slider.value = val;
}
// ---------------- REMOTE SFTP / WEBDAV & GLOBAL MOUNTS ----------------
let targetRemotePaneIndex = 0;
async function openRemoteModal(paneIndex, asGlobalAdmin = false) {
targetRemotePaneIndex = paneIndex;
document.getElementById('remote-test-status').style.display = 'none';
const pathIn = document.getElementById('remote-path');
if (pathIn) pathIn.value = '';
const isAdmin = App.user?.role === 'admin';
const globalSection = document.getElementById('remote-global-mount-section');
if (globalSection) globalSection.style.display = isAdmin ? 'block' : 'none';
const globalCheckbox = document.getElementById('remote-is-global-mount');
if (globalCheckbox) {
globalCheckbox.checked = asGlobalAdmin;
toggleRemoteGlobalMountUI(asGlobalAdmin);
}
// Populate users list for mount permissions
if (isAdmin) {
const userContainer = document.getElementById('remote-mount-users-checkboxes');
if (userContainer) {
userContainer.innerHTML = '<div>Loading users...</div>';
try {
const resp = await fetch('/api/auth/users', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const users = await resp.json();
userContainer.innerHTML = users.map(u => `
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer; font-weight: normal;">
<input type="checkbox" class="mount-user-cb" value="${escapeHtml(u.username)}" checked>
<span>${escapeHtml(u.username)} ${u.nickname ? `(${escapeHtml(u.nickname)})` : ''}</span>
</label>
`).join('');
}
} catch (err) {
userContainer.innerHTML = '<div>Failed to load users</div>';
}
}
}
showModal('remote-modal');
updateRemoteProtoUI();
}
function toggleRemoteGlobalMountUI(checked) {
const opts = document.getElementById('remote-global-mount-options');
const saveBtn = document.getElementById('btn-save-global-mount');
const connectBtn = document.getElementById('btn-connect-remote');
if (opts) opts.style.display = checked ? 'block' : 'none';
if (saveBtn) saveBtn.style.display = checked ? 'inline-flex' : 'none';
if (connectBtn) connectBtn.textContent = checked ? 'Connect & Also Mount to Active Pane' : 'Connect to Active Pane';
}
function toggleMountAllUsers(checked) {
document.querySelectorAll('.mount-user-cb').forEach(cb => {
cb.checked = checked;
});
}
async function saveGlobalRemoteMount() {
const proto = document.getElementById('remote-proto-select').value;
const host = document.getElementById('remote-host')?.value.trim() || '';
const port = parseInt(document.getElementById('remote-port')?.value || '22', 10);
const user = document.getElementById('remote-user')?.value.trim() || '';
const pass = document.getElementById('remote-pass')?.value || '';
const path = document.getElementById('remote-path')?.value || '/';
const mountName = document.getElementById('remote-global-mount-name')?.value.trim() || `${proto.toUpperCase()} - ${host}`;
let target_uri = '';
if (proto === 'sftp') {
if (!host) { showToast('Please specify host', 'warning'); return; }
const userAuth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : '';
const cleanPath = path && path !== '~' ? (path.startsWith('/') ? path : '/' + path) : '';
target_uri = `sftp://${userAuth}${host}:${port}${cleanPath}`;
} else if (proto === 'smb') {
const share = document.getElementById('remote-smb-share')?.value || '';
const domain = document.getElementById('remote-smb-domain')?.value || '';
if (!host || !share) { showToast('Please specify Samba host and share name', 'warning'); return; }
const domPrefix = domain && domain !== 'WORKGROUP' ? `${domain};` : '';
const userAuth = user ? (pass ? `${domPrefix}${user}:${pass}@` : `${domPrefix}${user}@`) : '';
const portSuffix = port !== 445 ? `:${port}` : '';
const sub = path.startsWith('/') ? path : '/' + path;
target_uri = `smb://${userAuth}${host}${portSuffix}/${share}${sub === '/' ? '' : sub}`;
} else if (proto === 'nfs') {
const exportPath = document.getElementById('remote-nfs-export')?.value || '/';
if (!host) { showToast('Please specify NFS host', 'warning'); return; }
const portSuffix = port !== 2049 ? `:${port}` : '';
const cleanExport = exportPath.startsWith('/') ? exportPath : '/' + exportPath;
const sub = path && path !== '/' ? (path.startsWith('/') ? path : '/' + path) : '';
target_uri = `nfs://${host}${portSuffix}${cleanExport}${sub}`;
} else if (proto === 'webdav') {
if (!host) { showToast('Please specify WebDAV URL', 'warning'); return; }
target_uri = `webdav://${host.replace(/^https?:\/\//, '')}${path.startsWith('/') ? path : '/' + path}`;
} else if (proto === 's3') {
const bucket = document.getElementById('remote-bucket')?.value || '';
if (!bucket) { showToast('Please specify S3 bucket name', 'warning'); return; }
target_uri = `s3://${bucket}${path.startsWith('/') ? path : '/' + path}`;
} else if (proto === 'hetzner-box') {
const hMode = document.getElementById('hetzner-mode')?.value || 'sftp';
const userAuth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : '';
if (hMode === 'sftp') {
target_uri = `sftp://${userAuth}${host}:${port}${path.startsWith('/') ? path : '/' + path}`;
} else {
target_uri = `webdav://${host.replace(/^https?:\/\//, '')}${path.startsWith('/') ? path : '/' + path}`;
}
} else {
target_uri = path;
}
const allUsers = document.getElementById('remote-mount-all-users')?.checked;
const allowed_users = [];
if (allUsers) {
allowed_users.push('*');
} else {
document.querySelectorAll('.mount-user-cb:checked').forEach(cb => {
allowed_users.push(cb.value);
});
if (allowed_users.length === 0) {
showToast('Please select at least one user or choose All Users (*)', 'warning');
return;
}
}
try {
const resp = await fetch('/api/mounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
name: mountName,
protocol: proto,
target_uri: target_uri,
options_json: JSON.stringify({ host, port, user, pass, path }),
allowed_users: allowed_users
})
});
if (resp.ok) {
showToast(`Global mount '${mountName}' saved! Assigned users will see it in Favorites automatically.`, 'success');
closeModal('remote-modal');
loadAdminGlobalMounts();
} else {
showToast(`Failed to save global mount: ${await resp.text()}`, 'error');
}
} catch (err) {
showToast(`Save error: ${err}`, 'error');
}
}
async function loadAdminGlobalMounts() {
const tbody = document.getElementById('admin-global-mounts-tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 16px;">Loading global network mounts...</td></tr>';
const protoIcons = {
'smb': 'share-2',
'nfs': 'server',
's3': 'cloud',
'sftp': 'terminal',
'webdav': 'globe',
'hetzner-box': 'box',
'proton': 'shield'
};
try {
const resp = await fetch('/api/mounts/all', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const mounts = await resp.json();
if (mounts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 16px; color: var(--text-dim);">No global network mounts configured yet. Click "+ Add Global Mount" above to create one.</td></tr>';
return;
}
tbody.innerHTML = mounts.map(m => {
let allowed = [];
try {
allowed = typeof m.allowed_users === 'string' ? JSON.parse(m.allowed_users) : m.allowed_users;
} catch (_) {
allowed = ['*'];
}
const hasAll = allowed.includes('*');
return `
<tr class="admin-user-row">
<td class="admin-user-cell">
<div style="font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 8px;">
<i data-lucide="${protoIcons[m.protocol] || 'network'}" style="width: 16px; color: var(--accent);"></i>
<span>${escapeHtml(m.name)}</span>
</div>
</td>
<td class="admin-user-cell">
<span class="badge" style="font-size: 10px; text-transform: uppercase;">${escapeHtml(m.protocol)}</span>
</td>
<td class="admin-user-cell file-cell-mono" style="font-size: 11px;">
${escapeHtml(m.target_uri)}
</td>
<td class="admin-user-cell">
${hasAll ? `
<span class="badge" style="font-size: 10px; background: rgba(34, 197, 94, 0.2); color: var(--success); border: 1px solid rgba(34, 197, 94, 0.35);">ALL USERS (*)</span>
` : `
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
${allowed.map(u => `<span class="badge" style="font-size: 10px; background: rgba(56, 189, 248, 0.15); color: var(--info); border: 1px solid rgba(56, 189, 248, 0.3);">${escapeHtml(u)}</span>`).join('')}
</div>
`}
</td>
<td class="admin-user-cell" style="text-align: center;">
<div style="display: flex; gap: 6px; justify-content: center;">
<button class="btn" style="padding: 5px 10px; font-size: 11px;" onclick="loadPaneDirectory(App.activePaneIndex, ${escapeJsStr(m.target_uri)}); closeModal('admin-panel-modal');" title="Open in active panel">
<i data-lucide="folder-open" style="width: 12px;"></i> Open
</button>
<button class="btn btn-danger" style="padding: 5px 8px; font-size: 11px;" onclick="deleteGlobalMount(${m.id}, ${escapeJsStr(m.name)})" title="Delete Mount">
<i data-lucide="trash-2" style="width: 12px;"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" style="color: var(--danger); padding: 16px;">Failed to load global mounts: ${err}</td></tr>`;
}
}
async function deleteGlobalMount(id, name) {
const confirmed = await showConfirmDialog({
title: 'Remove Global Mount',
subtitle: 'Remote Storage Management',
message: `Are you sure you want to remove global network mount '${name}'?`,
icon: 'network',
type: 'danger',
confirmText: 'Remove Mount',
cancelText: 'Cancel',
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/mounts/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast(`Global mount '${name}' removed`, 'success');
loadAdminGlobalMounts();
} else {
showToast(`Failed to delete mount: ${await resp.text()}`, 'error');
}
} catch (err) {
showToast(`Delete error: ${err}`, 'error');
}
}
function syncHetznerFields() {
const user = document.getElementById('hetzner-user')?.value.trim() || 'u123456';
const mode = document.getElementById('hetzner-mode')?.value || 'sftp';
const preview = document.getElementById('hetzner-preview-host');
const hostInput = document.getElementById('remote-host');
const portInput = document.getElementById('remote-port');
const userInput = document.getElementById('remote-user');
if (mode === 'sftp') {
if (preview) preview.textContent = `${user}.your-storagebox.de:23 (SFTP)`;
if (hostInput) hostInput.value = `${user}.your-storagebox.de`;
if (portInput) portInput.value = '23';
if (userInput) userInput.value = user;
} else {
if (preview) preview.textContent = `https://${user}.your-storagebox.de (WebDAV)`;
if (hostInput) hostInput.value = `https://${user}.your-storagebox.de`;
if (portInput) portInput.value = '443';
if (userInput) userInput.value = user;
}
}
async function updateRemoteProtoUI() {
const proto = document.getElementById('remote-proto-select').value;
const portGroup = document.getElementById('remote-port-group');
const portInput = document.getElementById('remote-port');
const s3Group = document.getElementById('remote-s3-group');
const smbGroup = document.getElementById('remote-smb-group');
const nfsGroup = document.getElementById('remote-nfs-group');
const standardGroup = document.getElementById('remote-standard-group');
const protonGroup = document.getElementById('remote-proton-group');
const hetznerGroup = document.getElementById('remote-hetzner-group');
const hostInput = document.getElementById('remote-host');
const hostLabel = document.getElementById('remote-host-label');
const userLabel = document.getElementById('remote-user-label');
const passLabel = document.getElementById('remote-pass-label');
if (hetznerGroup) hetznerGroup.style.display = proto === 'hetzner-box' ? 'block' : 'none';
if (smbGroup) smbGroup.style.display = proto === 'smb' ? 'grid' : 'none';
if (nfsGroup) nfsGroup.style.display = proto === 'nfs' ? 'block' : 'none';
if (proto === 'sftp') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'block';
if (portInput) portInput.value = '22';
if (s3Group) s3Group.style.display = 'none';
if (hostLabel) hostLabel.textContent = 'Server Host / IP:';
if (userLabel) userLabel.textContent = 'SSH Username:';
if (passLabel) passLabel.textContent = 'SSH Password / Key:';
if (hostInput) hostInput.placeholder = 'e.g. 192.168.1.100 or sftp.example.com';
} else if (proto === 'smb') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'block';
if (portInput) portInput.value = '445';
if (s3Group) s3Group.style.display = 'none';
if (hostLabel) hostLabel.textContent = 'Samba Server / NAS Host:';
if (userLabel) userLabel.textContent = 'SMB Username (or blank for guest):';
if (passLabel) passLabel.textContent = 'SMB Password:';
if (hostInput) hostInput.placeholder = 'e.g. 192.168.1.50 or my-nas.local';
} else if (proto === 'nfs') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'block';
if (portInput) portInput.value = '2049';
if (s3Group) s3Group.style.display = 'none';
if (hostLabel) hostLabel.textContent = 'NFS Server Host / IP:';
if (userLabel) userLabel.textContent = 'NFS User (Optional):';
if (passLabel) passLabel.textContent = 'Password (Optional):';
if (hostInput) hostInput.placeholder = 'e.g. 192.168.1.50 or nfs-server.local';
} else if (proto === 'hetzner-box') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'block';
if (s3Group) s3Group.style.display = 'none';
if (hostLabel) hostLabel.textContent = 'Storage Box Host:';
if (userLabel) userLabel.textContent = 'Storage Box User:';
if (passLabel) passLabel.textContent = 'Storage Box Password / Key:';
syncHetznerFields();
} else if (proto === 'webdav') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'none';
if (s3Group) s3Group.style.display = 'none';
if (hostLabel) hostLabel.textContent = 'WebDAV URL:';
if (userLabel) userLabel.textContent = 'WebDAV Username:';
if (passLabel) passLabel.textContent = 'WebDAV Password / Token:';
if (hostInput) hostInput.placeholder = 'e.g. http://192.168.1.100:80/remote.php/webdav/';
} else if (proto === 's3') {
if (standardGroup) standardGroup.style.display = 'block';
if (protonGroup) protonGroup.style.display = 'none';
if (portGroup) portGroup.style.display = 'none';
if (s3Group) s3Group.style.display = 'grid';
if (hostLabel) hostLabel.textContent = 'S3 Endpoint URL:';
if (userLabel) userLabel.textContent = 'Access Key ID:';
if (passLabel) passLabel.textContent = 'Secret Access Key:';
if (hostInput) hostInput.placeholder = 'e.g. https://s3.amazonaws.com or https://fsn1.your-objectstorage.com';
} else if (proto === 'proton') {
if (standardGroup) standardGroup.style.display = 'none';
if (protonGroup) protonGroup.style.display = 'block';
const badge = document.getElementById('proton-cli-status-badge');
if (badge) badge.innerHTML = '<span style="color: var(--accent);">Checking Proton Drive CLI / daemon status...</span>';
try {
const resp = await fetch('/api/remotes/proton/status', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const st = await resp.json();
if (st.installed) {
badge.innerHTML = `🟢 <b>Detected Backend:</b> ${st.cli_type} (${st.version || 'active'}) | <b>Auth:</b> ${st.authenticated ? '✅ Ready' : '⚠️ Not Logged In'}`;
} else {
badge.innerHTML = `⚪ <b>Proton CLI Not Detected:</b> Run <code>proton-drive login</code> or configure an <code>rclone</code> proton remote.`;
}
}
} catch (e) {
if (badge) badge.textContent = `Status check error: ${e}`;
}
}
}
async function testRemoteConnection() {
let proto = document.getElementById('remote-proto-select').value;
const host = document.getElementById('remote-host')?.value || '';
const port = parseInt(document.getElementById('remote-port')?.value || '22', 10);
const user = document.getElementById('remote-user')?.value || '';
const pass = document.getElementById('remote-pass')?.value || '';
let bucket = document.getElementById('remote-bucket')?.value || '';
let region = document.getElementById('remote-region')?.value || 'us-east-1';
if (proto === 'hetzner-box') {
const hMode = document.getElementById('hetzner-mode')?.value || 'sftp';
proto = hMode;
} else if (proto === 'smb') {
bucket = document.getElementById('remote-smb-share')?.value || '';
region = document.getElementById('remote-smb-domain')?.value || 'WORKGROUP';
} else if (proto === 'nfs') {
bucket = document.getElementById('remote-nfs-export')?.value || '/';
}
const status = document.getElementById('remote-test-status');
status.style.display = 'block';
status.style.color = 'var(--accent)';
status.textContent = 'Testing connection...';
try {
const resp = await fetch('/api/remotes/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ protocol: proto, host, port, user, pass, bucket, region })
});
if (resp.ok) {
const data = await resp.json();
status.style.color = data.success ? 'var(--success)' : 'var(--warning)';
status.textContent = `${data.success ? '✓' : '⚠️'} ${data.message}`;
} else {
status.style.color = 'var(--danger)';
status.textContent = `✗ ${await resp.text()}`;
}
} catch (e) {
status.style.color = 'var(--danger)';
status.textContent = `✗ Error: ${e}`;
}
}
function connectRemoteToActivePane() {
const proto = document.getElementById('remote-proto-select').value;
const host = document.getElementById('remote-host')?.value.trim() || '';
const port = parseInt(document.getElementById('remote-port')?.value || '22', 10);
const user = document.getElementById('remote-user')?.value.trim() || '';
const pass = document.getElementById('remote-pass')?.value || '';
const bucket = document.getElementById('remote-bucket')?.value.trim() || '';
const path = document.getElementById('remote-path')?.value || '/';
let remoteUrl = '';
if (proto === 'sftp') {
if (!host) { showToast('Please specify a server host / URL', 'warning'); return; }
const userAuth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : '';
const cleanPath = path && path !== '~' ? (path.startsWith('/') ? path : '/' + path) : '';
remoteUrl = `sftp://${userAuth}${host}:${port}${cleanPath}`;
} else if (proto === 'smb') {
const share = document.getElementById('remote-smb-share')?.value || '';
const domain = document.getElementById('remote-smb-domain')?.value || '';
if (!host) { showToast('Please specify Samba / NAS host', 'warning'); return; }
if (!share) { showToast('Please specify Share Name (e.g. public or data)', 'warning'); return; }
const domPrefix = domain && domain !== 'WORKGROUP' ? `${domain};` : '';
const userAuth = user ? (pass ? `${domPrefix}${user}:${pass}@` : `${domPrefix}${user}@`) : '';
const portSuffix = port !== 445 ? `:${port}` : '';
const sub = path.startsWith('/') ? path : '/' + path;
remoteUrl = `smb://${userAuth}${host}${portSuffix}/${share}${sub === '/' ? '' : sub}`;
} else if (proto === 'nfs') {
const exportPath = document.getElementById('remote-nfs-export')?.value || '/';
if (!host) { showToast('Please specify NFS host', 'warning'); return; }
const portSuffix = port !== 2049 ? `:${port}` : '';
const cleanExport = exportPath.startsWith('/') ? exportPath : '/' + exportPath;
const sub = path && path !== '/' ? (path.startsWith('/') ? path : '/' + path) : '';
remoteUrl = `nfs://${host}${portSuffix}${cleanExport}${sub}`;
} else if (proto === 'hetzner-box') {
const hMode = document.getElementById('hetzner-mode')?.value || 'sftp';
const userAuth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : '';
if (hMode === 'sftp') {
remoteUrl = `sftp://${userAuth}${host}:${port}${path.startsWith('/') ? path : '/' + path}`;
} else {
remoteUrl = `webdav://${host.replace(/^https?:\/\//, '')}${path.startsWith('/') ? path : '/' + path}`;
}
} else if (proto === 'webdav') {
if (!host) { showToast('Please specify a server host / URL', 'warning'); return; }
remoteUrl = `webdav://${host.replace(/^https?:\/\//, '')}${path.startsWith('/') ? path : '/' + path}`;
} else if (proto === 's3') {
if (!host) { showToast('Please specify S3 endpoint', 'warning'); return; }
remoteUrl = `s3://${bucket}${path.startsWith('/') ? path : '/' + path}`;
} else if (proto === 'proton') {
remoteUrl = `proton://${path.startsWith('/') ? path.substring(1) : path}`;
}
closeModal('remote-modal');
loadPaneDirectory(targetRemotePaneIndex, remoteUrl);
}
function disconnectPaneRemote(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
const currentPath = pane.path || pane.currentPath || '';
if (currentPath.startsWith('vault://')) {
const raw = currentPath.replace('vault://', '');
const [vaultFile] = raw.split('#');
const parentDir = vaultFile.substring(0, vaultFile.lastIndexOf('/')) || '/';
fetch('/api/vault/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path: vaultFile })
}).catch(console.error);
loadPaneDirectory(paneIndex, parentDir);
showToast(`🔒 Locked vault. Master key purged from RAM.`, 'info');
return;
}
if (currentPath.startsWith('archive://')) {
const raw = currentPath.replace('archive://', '');
const [arch] = raw.split('#');
const parentDir = arch.substring(0, arch.lastIndexOf('/')) || '/';
loadPaneDirectory(paneIndex, parentDir);
showToast(`📦 Closed archive. Returned to ${parentDir}`, 'info');
return;
}
if (currentPath.startsWith('client://')) {
const parsed = parseClientPath(currentPath);
if (parsed) {
clientVfsState.mounts.delete(parsed.mountName);
removeClientDirectoryHandle(parsed.mountName).catch(() => {});
}
const targetLocal = App.user?.home_dir || '/';
loadPaneDirectory(paneIndex, targetLocal);
showToast(`🔌 Unmounted client local folder. Returned to ${targetLocal}`, 'info');
return;
}
// Purge cached session credentials for this host / remote URI
if (App.sessionCredentials) {
const cleanCurrent = sanitizeCredentials(currentPath);
for (const key in App.sessionCredentials) {
if (currentPath.includes(key) || key.includes(cleanCurrent) || currentPath.startsWith(key)) {
delete App.sessionCredentials[key];
}
}
}
const targetLocal = App.user?.home_dir || '/';
loadPaneDirectory(paneIndex, targetLocal);
const protoName = (currentPath.split('://')[0] || 'Remote').toUpperCase();
showToast(`🔌 Disconnected from ${protoName} server. Returned to ${targetLocal}`, 'info');
}
// ---------------- INTEGRATED TERMINAL (PTY & WEBSOCKET) ----------------
let termWs = null;
let termOpen = false;
let termInstance = null;
let termFitAddon = null;
let termConnectTime = 0;
function ensureTerminalOutputElement() {
let termOutput = document.getElementById('terminal-output');
const drawer = document.getElementById('terminal-drawer');
if (!termOutput && drawer) {
termOutput = document.createElement('div');
termOutput.className = 'terminal-body';
termOutput.id = 'terminal-output';
termOutput.tabIndex = 0;
drawer.appendChild(termOutput);
if (termInstance) {
try { termInstance.dispose(); } catch (e) {}
termInstance = null;
termFitAddon = null;
}
}
return termOutput;
}
function stashDockedTerminal() {
const termOutput = document.getElementById('terminal-output');
const drawer = document.getElementById('terminal-drawer');
const panesGrid = document.getElementById('panes-grid');
if (termOutput && drawer && panesGrid && panesGrid.contains(termOutput)) {
drawer.appendChild(termOutput);
termOutput.style.width = '100%';
termOutput.style.height = '';
}
}
function initTerminalUI() {
const container = ensureTerminalOutputElement();
if (!container) return;
const TerminalCtor = (typeof Terminal !== 'undefined' && typeof Terminal === 'function')
? Terminal
: (window.Terminal ? (window.Terminal.Terminal || window.Terminal) : null);
if (!TerminalCtor) {
console.warn('Xterm Terminal constructor not available');
return;
}
if (!termInstance || !termInstance.element || !container.contains(termInstance.element)) {
container.innerHTML = '';
if (termInstance) {
try { termInstance.dispose(); } catch (e) {}
termInstance = null;
termFitAddon = null;
}
try {
termInstance = new TerminalCtor({
cursorBlink: true,
cursorStyle: 'bar',
fontFamily: '"JetBrainsMono Nerd Font", "JetBrainsMono Nerd Font Mono", "Symbols Nerd Font Mono", "JetBrains Mono", monospace',
fontSize: 13,
lineHeight: 1.25,
scrollback: 5000,
theme: {
background: '#090a0d',
foreground: '#f4f4f5',
cursor: '#f59e0b',
cursorAccent: '#090a0d',
selectionBackground: 'rgba(245, 158, 11, 0.35)',
black: '#18181b',
red: '#ef4444',
green: '#10b981',
yellow: '#f59e0b',
blue: '#38bdf8',
magenta: '#bd93f9',
cyan: '#7dcfff',
white: '#f4f4f5',
brightBlack: '#71717a',
brightRed: '#f87171',
brightGreen: '#34d399',
brightYellow: '#fbbf24',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#a5f3fc',
brightWhite: '#ffffff',
}
});
} catch (ctorErr) {
console.error('Failed to instantiate Terminal:', ctorErr);
return;
}
const FitCtor = (typeof FitAddon !== 'undefined')
? (FitAddon.FitAddon || FitAddon)
: (window.FitAddon ? (window.FitAddon.FitAddon || window.FitAddon) : null);
if (FitCtor) {
try {
termFitAddon = new FitCtor();
termInstance.loadAddon(termFitAddon);
} catch (fErr) {
console.warn('Could not load FitAddon:', fErr);
}
}
const WebLinksCtor = (typeof WebLinksAddon !== 'undefined')
? (WebLinksAddon.WebLinksAddon || WebLinksAddon)
: (window.WebLinksAddon ? (window.WebLinksAddon.WebLinksAddon || window.WebLinksAddon) : null);
if (WebLinksCtor) {
try {
termInstance.loadAddon(new WebLinksCtor());
} catch (wErr) {
console.warn('Could not load WebLinksAddon:', wErr);
}
}
try {
termInstance.open(container);
} catch (openErr) {
console.error('Failed to open Terminal into container:', openErr);
}
if (document.fonts) {
Promise.all([
document.fonts.load('13px "JetBrainsMono Nerd Font"'),
document.fonts.load('13px "JetBrainsMono Nerd Font Mono"'),
document.fonts.load('13px "Symbols Nerd Font Mono"'),
document.fonts.ready
]).then(() => {
if (termFitAddon) {
try {
termFitAddon.fit();
if (termInstance) termInstance.refresh(0, termInstance.rows - 1);
} catch (e) {}
}
});
}
termInstance.onData((data) => {
// Ignore residual Enter/newline keystroke leakage occurring during window open transition
if (Date.now() - termConnectTime < 250 && (data === '\r' || data === '\n' || data === '\r\n')) {
return;
}
if (termWs && termWs.readyState === WebSocket.OPEN) {
termWs.send(data);
}
});
let resizeDebounceTimer = null;
termInstance.onResize(({ cols, rows }) => {
// Avoid premature SIGWINCH resize events immediately on initial startup handshake
if (Date.now() - termConnectTime < 350) {
return;
}
if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(() => {
if (termWs && termWs.readyState === WebSocket.OPEN) {
termWs.send(JSON.stringify({ cols, rows, resize: true }));
}
}, 50);
});
}
if (termFitAddon) {
setTimeout(() => {
try { termFitAddon.fit(); } catch (e) {}
}, 50);
}
}
function toggleTerminal(forceState) {
if (forceState && typeof forceState === 'object' && forceState.stopPropagation) {
forceState.stopPropagation();
forceState = undefined;
}
const drawer = document.getElementById('terminal-drawer');
if (!drawer) return;
termOpen = typeof forceState === 'boolean' ? forceState : !termOpen;
if (termOpen) {
termConnectTime = Date.now();
localStorage.setItem('cd_terminal_open', '1');
drawer.classList.add('active');
// Ensure #terminal-output is safely inside #terminal-drawer
const termOutput = ensureTerminalOutputElement();
if (termOutput && !drawer.contains(termOutput)) {
drawer.appendChild(termOutput);
termOutput.style.width = '100%';
termOutput.style.height = '100%';
termOutput.style.display = 'flex';
termOutput.style.flexDirection = 'column';
}
// If any pane had terminal docked, undock it because it is now in the drawer
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'terminal') {
p.dockedTool = null;
localStorage.removeItem(`cd_pane_docked_${idx}`);
rebuildPaneDOM(idx);
}
});
const activePane = App.panes[App.activePaneIndex];
const cwd = (activePane && !activePane.path.includes('://')) ? activePane.path : (getUserDefaultHomeDir() || '/');
const cwdEl = document.getElementById('terminal-cwd-indicator');
if (cwdEl) cwdEl.textContent = cwd;
initTerminalUI();
const isWsActive = termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING);
if (!isWsActive) {
connectTerminal(cwd);
} else {
setTimeout(() => {
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
if (termInstance) termInstance.focus();
}, 80);
}
} else {
localStorage.setItem('cd_terminal_open', '0');
drawer.classList.remove('active');
}
if (window.lucide) lucide.createIcons();
}
function toggleTerminalFullscreen() {
const drawer = document.getElementById('terminal-drawer');
if (!drawer) return;
const isFull = drawer.classList.toggle('fullscreen');
localStorage.setItem('cd_terminal_fullscreen', isFull ? '1' : '0');
setTimeout(() => {
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
if (termInstance) termInstance.focus();
}, 100);
}
function clearTerminal() {
if (termInstance) {
termInstance.clear();
termInstance.focus();
} else {
const out = document.getElementById('terminal-output');
if (out) out.innerHTML = '';
}
}
function restoreTerminalState() {
const isAnyDocked = App.panes.some(p => p && p.dockedTool === 'terminal');
if (isAnyDocked) {
// Terminal is already mounted inside a docked pane by renderAllPanes()
return;
}
const wasOpen = localStorage.getItem('cd_terminal_open') === '1';
if (wasOpen) {
const wasFullscreen = localStorage.getItem('cd_terminal_fullscreen') === '1';
toggleTerminal(true);
if (wasFullscreen) {
const drawer = document.getElementById('terminal-drawer');
if (drawer) {
drawer.classList.add('fullscreen');
setTimeout(() => {
if (termFitAddon) termFitAddon.fit();
}, 120);
}
}
}
}
function getAppBasePath() {
const path = window.location.pathname;
if (!path || path === '/' || path.endsWith('/index.html')) {
return '';
}
return path.endsWith('/') ? path.slice(0, -1) : path.replace(/\/[^\/]*$/, '');
}
function getWsUrl(endpoint) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const basePath = getAppBasePath();
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${protocol}//${window.location.host}${basePath}${cleanEndpoint}`;
}
function connectTerminal(cwd) {
termConnectTime = Date.now();
if (termWs) {
const oldWs = termWs;
termWs = null;
oldWs.onopen = null;
oldWs.onmessage = null;
oldWs.onerror = null;
oldWs.onclose = null;
try { oldWs.close(); } catch (_) {}
}
// Reset any previous terminal buffer so a new session starts clean
if (termInstance) {
try { termInstance.reset(); } catch (_) {}
}
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
const cols = (termInstance && termInstance.cols > 0) ? termInstance.cols : 100;
const rows = (termInstance && termInstance.rows > 0) ? termInstance.rows : 24;
const cleanCwd = (cwd && typeof cwd === 'string') ? cwd : '/';
const token = App.token || localStorage.getItem('cd_token') || '';
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
const url = `${getWsUrl('/api/ws/terminal')}?cwd=${encodeURIComponent(cleanCwd)}&cols=${cols}&rows=${rows}${tokenParam}`;
try {
const thisWs = new WebSocket(url);
termWs = thisWs;
thisWs.binaryType = 'arraybuffer';
thisWs.onopen = () => {
if (termWs !== thisWs) return;
setTimeout(() => {
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
if (termInstance) {
termInstance.focus();
}
}, 80);
};
thisWs.onmessage = (e) => {
if (termWs !== thisWs) return;
if (termInstance) {
if (e.data instanceof ArrayBuffer) {
termInstance.write(new Uint8Array(e.data));
} else {
termInstance.write(e.data);
}
} else {
appendTerminalTextFallback(typeof e.data === 'string' ? e.data : new TextDecoder().decode(e.data));
}
};
thisWs.onerror = (err) => {
if (termWs !== thisWs) return;
console.warn('Terminal WebSocket error:', err);
};
thisWs.onclose = () => {
if (termWs !== thisWs) return;
termWs = null;
const dockedPaneIdx = App.panes.findIndex(p => p && p.dockedTool === 'terminal');
if (dockedPaneIdx !== -1) {
closeDockedTool(dockedPaneIdx);
} else {
toggleTerminal(false);
if (termInstance) {
try { termInstance.reset(); } catch (e) {}
}
}
};
} catch (wsErr) {
console.error('Failed to create Terminal WebSocket:', wsErr);
}
}
function appendTerminalTextFallback(str) {
const out = document.getElementById('terminal-output');
if (!out) return;
out.textContent += str;
out.scrollTop = out.scrollHeight;
}
// ---------------- TERACOPY-STYLE FLOATING TASK & TRANSFER MANAGER ----------------
let tasksPollTimer = null;
let lastKnownTasksList = [];
let wasAnyRunningLastCheck = false;
let fastPollExpiry = 0;
function startTasksPolling() {
if (tasksPollTimer) clearTimeout(tasksPollTimer);
initTaskWindowDragResize();
initMobileDrawerGestures();
scheduleNextTaskPoll(300);
}
function requestTasksFastBurst(durationMs = 4000) {
fastPollExpiry = Math.max(fastPollExpiry, Date.now() + durationMs);
scheduleNextTaskPoll(30);
}
let autoOpenTasksTimer = null;
function triggerAutoOpenTasksIfNeeded(delayMs = 350) {
if (!App.autoOpenTasks || App.taskManagerVisible) return;
if (autoOpenTasksTimer) clearTimeout(autoOpenTasksTimer);
autoOpenTasksTimer = setTimeout(() => {
autoOpenTasksTimer = null;
const isAnyRunning = lastKnownTasksList.some(t => t.status === 'running');
if (isAnyRunning && App.autoOpenTasks && !App.taskManagerVisible) {
openFloatingTaskManager();
}
}, delayMs);
}
function scheduleNextTaskPoll(delayMs) {
if (tasksPollTimer) clearTimeout(tasksPollTimer);
tasksPollTimer = setTimeout(pollTasks, delayMs);
}
function toggleFloatingTaskManager() {
const win = document.getElementById('floating-task-manager');
if (win && win.classList.contains('active') && !win.classList.contains('minimized')) {
closeFloatingTaskManager();
} else {
openFloatingTaskManager();
}
}
function openFloatingTaskManager() {
const win = document.getElementById('floating-task-manager');
const pill = document.getElementById('tasks-pill');
const backdrop = document.getElementById('task-drawer-backdrop');
if (win) {
win.classList.remove('minimized', 'drawer-dragging');
win.classList.add('active');
if (window.innerWidth <= 768) {
win.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)';
win.style.transform = 'translateY(0)';
} else {
win.style.transform = '';
win.style.transition = '';
}
}
if (backdrop && window.innerWidth <= 768) {
backdrop.classList.add('active');
backdrop.style.transition = 'opacity 0.25s ease';
backdrop.style.opacity = '1';
}
if (pill) pill.classList.remove('active');
App.taskManagerVisible = true;
App.taskManagerMinimized = false;
const sel = document.getElementById('setting-task-verbosity');
if (sel) sel.value = App.taskVerbosity;
applyTaskVerbosityUI(App.taskVerbosity);
initMobileDrawerGestures();
pollTasks();
}
function closeFloatingTaskManager() {
const win = document.getElementById('floating-task-manager');
const backdrop = document.getElementById('task-drawer-backdrop');
if (win) {
if (window.innerWidth <= 768) {
win.style.transition = 'transform 0.22s cubic-bezier(0.16, 1, 0.3, 1)';
win.style.transform = 'translateY(100%)';
if (backdrop) {
backdrop.style.transition = 'opacity 0.22s ease';
backdrop.style.opacity = '0';
}
setTimeout(() => {
win.classList.remove('active', 'minimized', 'maximized', 'drawer-dragging');
win.style.transform = '';
win.style.transition = '';
if (backdrop) {
backdrop.classList.remove('active');
backdrop.style.opacity = '';
}
}, 220);
} else {
win.classList.remove('active', 'minimized', 'maximized');
win.style.transform = '';
win.style.transition = '';
if (backdrop) {
backdrop.classList.remove('active');
backdrop.style.opacity = '';
}
}
}
App.taskManagerVisible = false;
App.taskManagerMinimized = false;
updateTasksPillState(lastKnownTasksList);
}
function minimizeFloatingTaskManager() {
const win = document.getElementById('floating-task-manager');
const backdrop = document.getElementById('task-drawer-backdrop');
if (win) win.classList.add('minimized');
if (backdrop) {
backdrop.classList.remove('active');
backdrop.style.opacity = '';
}
App.taskManagerMinimized = true;
updateTasksPillState(lastKnownTasksList);
}
function initMobileDrawerGestures() {
const win = document.getElementById('floating-task-manager');
const pullBar = win?.querySelector('.drawer-pull-bar');
const header = document.getElementById('task-win-header');
const backdrop = document.getElementById('task-drawer-backdrop');
const peekBar = document.getElementById('mobile-task-peek-bar');
if (!win || win.dataset.drawerGesturesInit) return;
win.dataset.drawerGesturesInit = 'true';
// --- 1. Pull DOWN to close (when drawer is open) ---
let downStartX = 0, downStartY = 0, downStartTime = 0;
let isDraggingDown = false;
let currentDownDy = 0;
function onDownTouchStart(e) {
if (window.innerWidth > 768) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
downStartX = e.touches[0].clientX;
downStartY = e.touches[0].clientY;
downStartTime = Date.now();
isDraggingDown = false;
currentDownDy = 0;
window.addEventListener('touchmove', onDownTouchMove, { passive: false });
window.addEventListener('touchend', onDownTouchEnd, { passive: true });
window.addEventListener('touchcancel', onDownTouchEnd, { passive: true });
}
function onDownTouchMove(e) {
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const dx = Math.abs(currentX - downStartX);
const dy = currentY - downStartY;
if (!isDraggingDown) {
if (dy > 15 && dy > dx * 1.1) {
isDraggingDown = true;
win.classList.add('drawer-dragging');
win.style.transition = 'none';
} else {
return;
}
}
currentDownDy = Math.max(0, dy);
win.style.transform = `translateY(${currentDownDy}px)`;
if (backdrop) {
const opacity = Math.max(0, 1 - currentDownDy / 260);
backdrop.style.opacity = `${opacity}`;
}
if (e.cancelable) e.preventDefault();
}
function onDownTouchEnd() {
window.removeEventListener('touchmove', onDownTouchMove);
window.removeEventListener('touchend', onDownTouchEnd);
window.removeEventListener('touchcancel', onDownTouchEnd);
if (!isDraggingDown) return;
isDraggingDown = false;
win.classList.remove('drawer-dragging');
const duration = Date.now() - downStartTime;
const velocity = currentDownDy / Math.max(1, duration);
if (currentDownDy > 70 || velocity > 0.4) {
closeFloatingTaskManager();
} else {
win.style.transition = 'transform 0.22s cubic-bezier(0.16, 1, 0.3, 1)';
win.style.transform = 'translateY(0)';
if (backdrop) {
backdrop.style.transition = 'opacity 0.22s ease';
backdrop.style.opacity = '1';
}
}
}
if (pullBar) pullBar.addEventListener('touchstart', onDownTouchStart, { passive: true });
if (header) header.addEventListener('touchstart', onDownTouchStart, { passive: true });
// --- 2. Pull UP to open (from persistent bottom peek tab) ---
if (peekBar) {
let upStartX = 0, upStartY = 0, upStartTime = 0;
let isDraggingUp = false;
let currentUpDy = 0;
let drawerH = 0;
peekBar.addEventListener('touchstart', (e) => {
if (window.innerWidth > 768) return;
upStartX = e.touches[0].clientX;
upStartY = e.touches[0].clientY;
upStartTime = Date.now();
isDraggingUp = false;
currentUpDy = 0;
drawerH = win.offsetHeight || 520;
window.addEventListener('touchmove', onUpTouchMove, { passive: false });
window.addEventListener('touchend', onUpTouchEnd, { passive: true });
window.addEventListener('touchcancel', onUpTouchEnd, { passive: true });
}, { passive: false });
function onUpTouchMove(e) {
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const dx = Math.abs(currentX - upStartX);
const dy = upStartY - currentY; // positive when pulling UP
if (!isDraggingUp) {
if (dy > 12 && dy > dx * 1.1) {
isDraggingUp = true;
drawerH = win.offsetHeight || 520;
win.classList.remove('minimized');
win.classList.add('drawer-dragging');
win.style.transition = 'none';
win.style.transform = `translateY(${Math.max(0, drawerH - dy)}px)`;
if (backdrop) {
backdrop.classList.add('active');
backdrop.style.opacity = '0';
}
} else {
return;
}
}
currentUpDy = dy;
const targetTranslate = Math.max(0, drawerH - dy);
win.style.transform = `translateY(${targetTranslate}px)`;
if (backdrop) {
const opacity = Math.min(1, dy / (drawerH * 0.6));
backdrop.style.opacity = `${opacity}`;
}
if (e.cancelable) e.preventDefault();
}
function onUpTouchEnd(e) {
window.removeEventListener('touchmove', onUpTouchMove);
window.removeEventListener('touchend', onUpTouchEnd);
window.removeEventListener('touchcancel', onUpTouchEnd);
if (!isDraggingUp) {
return;
}
isDraggingUp = false;
win.classList.remove('drawer-dragging');
const duration = Date.now() - upStartTime;
const velocity = currentUpDy / Math.max(1, duration);
if (currentUpDy > 60 || velocity > 0.35) {
openFloatingTaskManager();
} else {
closeFloatingTaskManager();
}
}
}
}
function restoreFloatingTaskManager() {
openFloatingTaskManager();
}
function maximizeFloatingTaskManager() {
const win = document.getElementById('floating-task-manager');
if (win) win.classList.toggle('maximized');
}
function setTaskVerbosity(mode) {
App.taskVerbosity = mode;
localStorage.setItem('cd_task_verbosity', mode);
applyTaskVerbosityUI(mode);
}
function applyTaskVerbosityUI(mode) {
const queueCont = document.getElementById('task-queue-container');
const logCont = document.getElementById('task-log-container');
const activeFileCard = document.getElementById('task-active-file-card');
if (mode === 'compact') {
if (queueCont) queueCont.style.display = 'none';
if (logCont) logCont.style.display = 'none';
if (activeFileCard) activeFileCard.style.display = 'block';
} else if (mode === 'log') {
if (queueCont) queueCont.style.display = 'none';
if (logCont) logCont.style.display = 'block';
if (activeFileCard) activeFileCard.style.display = 'block';
} else { // 'detailed' (TeraCopy standard)
if (queueCont) queueCont.style.display = 'block';
if (logCont) logCont.style.display = 'none';
if (activeFileCard) activeFileCard.style.display = 'block';
}
}
let knownCompletedTasks = new Set();
let tasksAutoPruneTimer = null;
async function pollTasks() {
if (!App.token) {
scheduleNextTaskPoll(2000);
return;
}
try {
const res = await fetch('/api/tasks', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const list = await res.json();
const serverTasks = Array.isArray(list) ? list : [];
// Preserve any client-side / cross-node / optimistic or remote tasks not present in local server tasks
const serverTaskIds = new Set(serverTasks.map(t => t.id));
const clientSideOrRemoteTasks = lastKnownTasksList.filter(t => {
if (!t || !t.id) return false;
if (serverTaskIds.has(t.id)) return false;
return t.id.startsWith('opt_') || t.id.startsWith('xnode_') || t.id.startsWith('client_') || (t.targetEndpoint && t.targetEndpoint !== '');
});
const combined = [...clientSideOrRemoteTasks, ...serverTasks];
lastKnownTasksList = combined;
const isAnyRunning = lastKnownTasksList.some(t => t.status === 'running');
if (isAnyRunning && !wasAnyRunningLastCheck && App.autoOpenTasks && !App.taskManagerVisible) {
triggerAutoOpenTasksIfNeeded(350);
}
if (!isAnyRunning && autoOpenTasksTimer) {
clearTimeout(autoOpenTasksTimer);
autoOpenTasksTimer = null;
}
wasAnyRunningLastCheck = isAnyRunning;
let shouldRefreshAll = false;
lastKnownTasksList.forEach(t => {
if (t.status === 'completed' && !knownCompletedTasks.has(t.id)) {
knownCompletedTasks.add(t.id);
shouldRefreshAll = true;
if (t.destination) {
handleNewlyTransferredItem(t.destination, t.name);
}
}
});
if (shouldRefreshAll) {
refreshAllPanes();
}
// If all tasks finished and none running, schedule auto-clean of completed records
if (!isAnyRunning && lastKnownTasksList.length > 0) {
if (!tasksAutoPruneTimer) {
tasksAutoPruneTimer = setTimeout(() => {
tasksAutoPruneTimer = null;
if (!App.taskManagerVisible) {
clearCompletedTasks();
}
}, 10000);
}
} else if (isAnyRunning && tasksAutoPruneTimer) {
clearTimeout(tasksAutoPruneTimer);
tasksAutoPruneTimer = null;
}
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
// Adaptive polling rate
if (isAnyRunning || Date.now() < fastPollExpiry) {
scheduleNextTaskPoll(60);
} else {
scheduleNextTaskPoll(2000);
}
return;
}
} catch (e) {
// Ignore polling errors
}
scheduleNextTaskPoll(2000);
}
function updateTasksPillState(list) {
const pill = document.getElementById('tasks-pill');
const headerBtn = document.getElementById('btn-header-tasks');
const headerCount = document.getElementById('header-task-count');
const headerSpeed = document.getElementById('header-task-speed');
const peekBadge = document.getElementById('mobile-peek-badge');
const peekText = document.getElementById('mobile-peek-text');
const safeList = Array.isArray(list) ? list : [];
const running = safeList.filter(t => t.status === 'running');
const failed = safeList.filter(t => t.status === 'failed');
const win = document.getElementById('floating-task-manager');
const isWinOpen = win && win.classList.contains('active') && !win.classList.contains('minimized');
const totalSpeed = running.reduce((acc, t) => acc + (t.speed_bytes_per_sec || 0), 0);
const speedStr = totalSpeed > 0 ? `${formatBytes(totalSpeed)}/s` : '';
// Update Header Button (Left of Profile) - Strictly static, count-only, jitter-free
if (headerBtn) {
if (headerSpeed) headerSpeed.style.display = 'none';
if (failed.length > 0) {
headerBtn.classList.remove('has-running');
headerBtn.classList.add('has-failed');
if (headerCount) headerCount.textContent = running.length > 0 ? `${running.length}` : '!';
headerBtn.title = `${failed.length} task(s) failed${running.length > 0 ? ` • ${running.length} running` : ''} (Click to open Task Manager)`;
} else if (running.length > 0) {
headerBtn.classList.remove('has-failed');
headerBtn.classList.add('has-running');
if (headerCount) headerCount.textContent = `${running.length}`;
headerBtn.title = `${running.length} active task(s) running (Click to open Task Manager)`;
} else {
headerBtn.classList.remove('has-running', 'has-failed');
if (headerCount) headerCount.textContent = '0';
headerBtn.title = 'Background Tasks & Transfers (Click to toggle)';
}
}
// Update Floating Pill (Desktop)
if (pill) {
if (running.length > 0 && !isWinOpen) {
pill.classList.add('active');
pill.style.display = 'flex';
const hasParanoid = running.some(t => t.paranoid);
const pillText = document.getElementById('tasks-pill-text');
const pillSpeed = document.getElementById('tasks-pill-speed');
if (pillText) pillText.textContent = `${hasParanoid ? '🛡️ ' : ''}${running.length} Job${running.length > 1 ? 's' : ''}`;
if (pillSpeed) pillSpeed.textContent = speedStr || (hasParanoid ? 'Verifying...' : 'Processing');
} else {
pill.classList.remove('active');
pill.style.display = 'none';
}
}
// Update Mobile Bottom Peek Tab Badge
if (peekBadge && peekText) {
if (failed.length > 0) {
peekBadge.style.display = 'inline-flex';
peekBadge.style.background = 'rgba(239, 68, 68, 0.2)';
peekBadge.style.color = '#ef4444';
peekText.textContent = `${failed.length} failed${running.length > 0 ? ` • ${running.length} active` : ''}`;
} else if (running.length > 0) {
peekBadge.style.display = 'inline-flex';
peekBadge.style.background = '';
peekBadge.style.color = '';
const hasParanoid = running.some(t => t.paranoid);
peekText.textContent = `${hasParanoid ? '🛡️ ' : ''}${running.length} active${speedStr ? ' • ' + speedStr : ''}`;
} else {
peekBadge.style.display = 'none';
}
}
}
function initTaskWindowDragResize() {
const win = document.getElementById('floating-task-manager');
if (!win) return;
if (win.dataset.resizeInitialized === 'true') return;
win.dataset.resizeInitialized = 'true';
const cornerTlHandle = document.getElementById('task-win-resize-corner');
const cornerBrHandle = document.getElementById('task-win-resize-corner-br');
const header = document.getElementById('task-win-header');
// Restore saved width, height, and coordinates if any
const savedWidth = localStorage.getItem('cd_task_win_width');
const savedHeight = localStorage.getItem('cd_task_win_height');
const savedLeft = localStorage.getItem('cd_task_win_left');
const savedTop = localStorage.getItem('cd_task_win_top');
if (savedWidth) applyTaskWindowWidth(parseInt(savedWidth, 10));
if (savedHeight) applyTaskWindowHeight(parseInt(savedHeight, 10));
if (savedLeft && savedTop && window.innerWidth > 768) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
win.style.right = 'auto';
win.style.bottom = 'auto';
}
// Header dragging across desktop
if (header) {
let isDraggingHeader = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 768) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDraggingHeader = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDraggingHeader) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
win.style.right = 'auto';
win.style.bottom = 'auto';
});
window.addEventListener('mouseup', () => {
if (isDraggingHeader) {
isDraggingHeader = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_task_win_left', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_task_win_top', parseInt(win.style.top, 10));
}
});
}
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
let dragMode = null; // 'corner-tl', 'corner-br'
let activeHandle = null;
function onDragStart(e, mode, handleElem) {
if (window.innerWidth <= 768) return; // Mobile drawer uses full width
if (win.classList.contains('maximized')) return;
dragMode = mode;
activeHandle = handleElem;
bringFloatingWindowToFront(win);
const pt = e.touches ? e.touches[0] : e;
startX = pt.clientX;
startY = pt.clientY;
const rect = win.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
startW = rect.width;
startH = rect.height;
// Anchor strictly to explicit top/left coordinates for deterministic geometry
win.style.left = `${startLeft}px`;
win.style.top = `${startTop}px`;
win.style.right = 'auto';
win.style.bottom = 'auto';
document.body.style.userSelect = 'none';
if (activeHandle) activeHandle.classList.add('dragging');
document.body.style.cursor = 'nwse-resize';
window.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragEnd);
window.addEventListener('touchmove', onDragMove, { passive: false });
window.addEventListener('touchend', onDragEnd);
}
function onDragMove(e) {
if (!dragMode) return;
const pt = e.touches ? e.touches[0] : e;
const clientX = pt.clientX;
const clientY = pt.clientY;
const minW = 360;
const maxW = Math.min(880, window.innerWidth - 32);
const minH = 220;
const maxH = Math.min(780, window.innerHeight - 48);
if (dragMode === 'corner-tl') {
const deltaX = clientX - startX;
const deltaY = clientY - startY;
let newW = startW - deltaX;
let newLeft = startLeft + deltaX;
if (newW < minW) {
newLeft = startLeft + (startW - minW);
newW = minW;
} else if (newW > maxW) {
newLeft = startLeft + (startW - maxW);
newW = maxW;
}
if (newLeft < 0) {
newW = startLeft + startW;
newLeft = 0;
}
let newH = startH - deltaY;
let newTop = startTop + deltaY;
if (newH < minH) {
newTop = startTop + (startH - minH);
newH = minH;
} else if (newH > maxH) {
newTop = startTop + (startH - maxH);
newH = maxH;
}
if (newTop < 35) {
newH = startTop + startH - 35;
newTop = 35;
}
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
applyTaskWindowWidth(newW);
applyTaskWindowHeight(newH);
}
if (dragMode === 'corner-br') {
const deltaX = clientX - startX;
const deltaY = clientY - startY;
let newW = Math.max(minW, Math.min(maxW, startW + deltaX));
let newH = Math.max(minH, Math.min(maxH, startH + deltaY));
if (startLeft + newW > window.innerWidth - 10) {
newW = Math.max(minW, window.innerWidth - 10 - startLeft);
}
if (startTop + newH > window.innerHeight - 10) {
newH = Math.max(minH, window.innerHeight - 10 - startTop);
}
applyTaskWindowWidth(newW);
applyTaskWindowHeight(newH);
}
if (e.preventDefault && e.cancelable) e.preventDefault();
}
function onDragEnd() {
if (!dragMode) return;
dragMode = null;
if (activeHandle) {
activeHandle.classList.remove('dragging');
activeHandle = null;
}
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem('cd_task_win_width', win.offsetWidth);
localStorage.setItem('cd_task_win_height', win.offsetHeight);
if (win.style.left) localStorage.setItem('cd_task_win_left', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_task_win_top', parseInt(win.style.top, 10));
window.removeEventListener('mousemove', onDragMove);
window.removeEventListener('mouseup', onDragEnd);
window.removeEventListener('touchmove', onDragMove);
window.removeEventListener('touchend', onDragEnd);
}
const handleMappings = [
{ el: cornerTlHandle, mode: 'corner-tl' },
{ el: cornerBrHandle, mode: 'corner-br' }
];
handleMappings.forEach(({ el, mode }) => {
if (!el) return;
el.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
onDragStart(e, mode, el);
});
el.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
onDragStart(e, mode, el);
}, { passive: false });
});
}
function applyTaskWindowWidth(w) {
const win = document.getElementById('floating-task-manager');
if (!win || window.innerWidth <= 768) return;
win.style.width = `${w}px`;
}
function applyTaskWindowHeight(h) {
const win = document.getElementById('floating-task-manager');
if (!win || window.innerWidth <= 768) return;
win.style.height = `${h}px`;
const queueCont = document.getElementById('task-queue-container');
const logCont = document.getElementById('task-log-container');
const internalHeight = Math.max(80, h - 220);
if (queueCont) queueCont.style.maxHeight = `${internalHeight}px`;
if (logCont) logCont.style.maxHeight = `${internalHeight}px`;
}
function joinItemPath(parentPath, childName) {
if (!parentPath) return childName || '';
if (!childName) return parentPath;
const childClean = String(childName).replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
// Custom and remote protocol URIs (e.g. sftp://, smb://, client://)
if (parentPath.includes('://')) {
const cleanParent = parentPath.replace(/\/+$/, '');
return `${cleanParent}/${childClean}`;
}
// Windows file paths (drive letters C:\... or UNC \\server\share)
const isWindows = /^[a-zA-Z]:[\\/]/.test(parentPath) || /^[a-zA-Z]:$/.test(parentPath) || parentPath.includes('\\');
if (isWindows) {
const winParent = parentPath.replace(/\//g, '\\');
if (/^[a-zA-Z]:\\?$/.test(winParent)) {
return `${winParent.substring(0, 2)}\\${childClean}`;
}
const cleanParent = winParent.replace(/\\+$/, '');
return `${cleanParent}\\${childClean}`;
}
// Unix / POSIX paths
if (parentPath === '/') {
return `/${childClean}`;
}
const cleanParent = parentPath.replace(/\/+$/, '');
return `${cleanParent}/${childClean}`;
}
function sanitizeCredentials(str) {
if (!str) return '';
return String(str)
.replace(/(:\/\/[^:@\s/]+):([^@\s/]+)@/g, '$1@')
.replace(/(:\/\/[^/@]+):([^@]+)@/g, '$1@');
}
function resolveAuthUri(path) {
if (!path || typeof path !== 'string') return path;
if (!path.includes('://')) return path;
// If path already contains embedded credentials, return as-is
if (path.includes('@')) {
const atParts = path.split('@')[0];
if (atParts.includes(':') && atParts.split(':').length > 2) {
return path;
}
}
if (!App.sessionCredentials) return path;
for (const key in App.sessionCredentials) {
const cred = App.sessionCredentials[key];
if (!cred || !cred.pass) continue;
const cleanPath = sanitizeCredentials(path);
const cleanKey = sanitizeCredentials(key);
if (cleanPath.startsWith(cleanKey) || (cred.host && path.includes(cred.host))) {
if (path.startsWith('sftp://')) {
const raw = path.slice(7);
const parts = raw.split('/');
const hostPort = parts[0].includes('@') ? parts[0].split('@')[1] : parts[0];
const sub = parts.slice(1).join('/');
return `sftp://${encodeURIComponent(cred.user)}:${encodeURIComponent(cred.pass)}@${hostPort}/${sub}`;
} else if (path.startsWith('smb://')) {
const raw = path.slice(6);
const parts = raw.split('/');
const hostPort = parts[0].includes('@') ? parts[0].split('@')[1] : parts[0];
const sub = parts.slice(1).join('/');
return `smb://${encodeURIComponent(cred.user)}:${encodeURIComponent(cred.pass)}@${hostPort}/${sub}`;
}
}
}
return path;
}
function getDownloadUrl(path, inline = false, paneIndex = null) {
if (!path) return '';
const resolved = resolveAuthUri(path);
const pIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const endpoint = typeof getPaneEndpoint === 'function' ? getPaneEndpoint(pIdx) : '';
const node = typeof getPaneNode === 'function' ? getPaneNode(pIdx) : null;
let url = `${endpoint}/api/fs/download?path=${encodeURIComponent(resolved)}`;
if (inline) url += '&inline=true';
const token = (node && node.id !== 'local' && node.auth_token) ? node.auth_token.trim() : (App.token || localStorage.getItem('cd_token'));
if (token) url += `&token=${encodeURIComponent(token)}`;
return url;
}
function copyTextToClipboard(text, successMsg = 'Copied to clipboard!') {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showToast(successMsg, 'success');
}).catch(() => {
fallbackCopyText(text, successMsg);
});
} else {
fallbackCopyText(text, successMsg);
}
}
function fallbackCopyText(text, successMsg) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
showToast(successMsg, 'success');
} catch (e) {
showToast('Failed to copy', 'warning');
}
document.body.removeChild(ta);
}
function renderFloatingTaskManager(list) {
const win = document.getElementById('floating-task-manager');
if (!win) return;
const running = list.filter(t => t.status === 'running');
const totalSpeed = running.reduce((acc, t) => acc + (t.speed_bytes_per_sec || 0), 0);
const speedStr = totalSpeed > 0 ? `${formatBytes(totalSpeed)}/s` : '0 B/s';
const anyParanoid = list.some(t => t.paranoid) || App.paranoidMode;
// 1. Header State
const titleEl = document.getElementById('task-header-title');
const indEl = document.getElementById('task-status-indicator');
const speedEl = document.getElementById('task-speed-badge');
const paranoidBadgeEl = document.getElementById('task-paranoid-badge');
if (titleEl) {
titleEl.textContent = `Transfers (${running.length} active${list.length > running.length ? `, ${list.length - running.length} done` : ''})`;
}
if (paranoidBadgeEl) {
paranoidBadgeEl.style.display = anyParanoid ? 'inline-block' : 'none';
}
if (indEl) {
if (running.length > 0) indEl.classList.add('active');
else indEl.classList.remove('active');
}
if (speedEl) {
speedEl.textContent = speedStr;
speedEl.style.display = totalSpeed > 0 ? 'inline-block' : 'none';
}
// 2. Batch Summary Progress (Total files & bytes across active running / batch jobs)
let totalBatchBytes = 0;
let totalProcessedBytes = 0;
let totalBatchFiles = 0;
let totalProcessedFiles = 0;
let totalVerified = 0;
let activeCurrentFile = null;
let activeCurBytes = 0;
let activeCurTotal = 0;
let activeSpeed = 0;
let activeCurHash = null;
const activeBatchList = running.length > 0 ? running : list;
activeBatchList.forEach(t => {
const isDone = t.status === 'completed';
const tBytes = t.total_bytes || (isDone ? (t.bytes_processed || 0) : 0);
const pBytes = isDone ? (t.total_bytes || t.bytes_processed || 0) : (t.bytes_processed || 0);
totalBatchBytes += tBytes;
totalProcessedBytes += pBytes;
totalBatchFiles += t.total_files || 1;
totalProcessedFiles += isDone ? (t.total_files || 1) : (t.files_processed || 0);
totalVerified += t.verified_files || 0;
if (t.status === 'running' && !activeCurrentFile) {
activeCurrentFile = t.phase_text ? t.phase_text : sanitizeCredentials(t.current_file || t.name);
activeCurBytes = t.current_file_bytes || t.bytes_processed || 0;
activeCurTotal = t.current_file_total_bytes || t.total_bytes || 0;
activeSpeed = t.speed_bytes_per_sec || 0;
activeCurHash = t.last_hash;
}
});
const overallPercent = totalBatchBytes > 0
? Math.min(100, Math.round((totalProcessedBytes / totalBatchBytes) * 100))
: (running.length === 0 && list.length > 0 ? 100 : 0);
const batchFill = document.getElementById('task-batch-progress-fill');
const batchPercent = document.getElementById('task-batch-percent-text');
const batchFiles = document.getElementById('task-batch-files-text');
const batchBytes = document.getElementById('task-batch-bytes-text');
const batchEta = document.getElementById('task-batch-eta-text');
if (batchFill) batchFill.style.width = `${overallPercent}%`;
if (batchPercent) batchPercent.textContent = `${overallPercent}%`;
if (batchFiles) {
batchFiles.textContent = `${totalProcessedFiles} / ${totalBatchFiles} files${totalVerified > 0 ? ` (🛡️ ${totalVerified} SHA-256 verified)` : ''}`;
}
if (batchBytes) batchBytes.textContent = `${formatBytes(totalProcessedBytes)} / ${formatBytes(totalBatchBytes)}`;
if (batchEta) {
if (running.length > 0 && totalSpeed > 0 && totalBatchBytes > totalProcessedBytes) {
const etaSec = Math.round((totalBatchBytes - totalProcessedBytes) / totalSpeed);
const mins = Math.floor(etaSec / 60);
const secs = etaSec % 60;
batchEta.textContent = `ETA: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
} else if (running.length === 0) {
batchEta.textContent = list.length > 0 ? (totalVerified > 0 ? '✓ Verified Complete' : '✓ Completed') : 'Idle';
} else {
const hasParanoid = running.some(t => t.paranoid);
batchEta.textContent = hasParanoid ? 'Verifying...' : 'Processing...';
}
}
// 3. Current Active File Card (TeraCopy Stream)
const curNameEl = document.getElementById('task-cur-filename');
const curSpeedEl = document.getElementById('task-cur-speed');
const curFillEl = document.getElementById('task-file-progress-fill');
const curBytesEl = document.getElementById('task-cur-bytes');
const curPercentEl = document.getElementById('task-cur-percent');
if (activeCurrentFile) {
if (curNameEl) {
curNameEl.innerHTML = `
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(activeCurrentFile)}</div>
${activeCurHash ? `<div style="font-size: 9.5px; color: #a3e635; font-family: var(--font-mono); margin-top: 2px;">🔑 ${escapeHtml(activeCurHash)}</div>` : ''}
`;
}
if (curSpeedEl) curSpeedEl.textContent = activeSpeed > 0 ? `${formatBytes(activeSpeed)}/s` : '';
const filePct = activeCurTotal > 0 ? Math.min(100, Math.round((activeCurBytes / activeCurTotal) * 100)) : (running.length > 0 ? 50 : 0);
if (curFillEl) curFillEl.style.width = `${filePct}%`;
if (curBytesEl) curBytesEl.textContent = activeCurTotal > 0 ? `${formatBytes(activeCurBytes)} / ${formatBytes(activeCurTotal)}` : `${formatBytes(activeCurBytes)}`;
if (curPercentEl) curPercentEl.textContent = `${filePct}%`;
} else {
const lastHash = list.find(t => t.last_hash)?.last_hash;
if (curNameEl) {
if (running.length === 0 && list.length > 0) {
curNameEl.innerHTML = `
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">All operations completed</div>
${lastHash ? `<div style="font-size: 9.5px; color: #a3e635; font-family: var(--font-mono); margin-top: 2px;">🔑 ${escapeHtml(lastHash)}</div>` : ''}
`;
} else {
curNameEl.textContent = running.length === 0 ? 'No active file transfer' : 'Processing file transfer...';
}
}
if (curSpeedEl) curSpeedEl.textContent = '';
if (curFillEl) curFillEl.style.width = running.length === 0 && list.length > 0 ? '100%' : '0%';
if (curBytesEl) curBytesEl.textContent = running.length === 0 && list.length > 0 ? `${formatBytes(totalBatchBytes)} / ${formatBytes(totalBatchBytes)}` : '0 B / 0 B';
if (curPercentEl) curPercentEl.textContent = running.length === 0 && list.length > 0 ? '100%' : '0%';
}
// 4. Job Queue List
const queueList = document.getElementById('task-queue-list');
if (queueList) {
if (list.length === 0) {
queueList.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 11px;">No active background jobs</div>';
} else {
queueList.innerHTML = list.map(t => {
const pct = t.total_bytes > 0 ? Math.min(100, Math.round((t.bytes_processed / t.total_bytes) * 100)) : (t.status === 'completed' ? 100 : 0);
const badgeClass = `task-badge-${t.status || 'running'}`;
const isRunning = t.status === 'running';
const isPaused = t.status === 'paused';
const safeName = sanitizeCredentials(t.name);
const safeSrc = sanitizeCredentials(t.source);
const safeDest = sanitizeCredentials(t.destination);
const safeErr = sanitizeCredentials(t.error_message || '');
return `
<div class="task-queue-item">
<div class="task-queue-details">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden;">
<span class="task-queue-name" title="${escapeHtml(safeName)}">${escapeHtml(safeName)}</span>
${isRunning && t.speed_bytes_per_sec > 0 ? `<span style="font-size: 9.5px; font-family: var(--font-mono); color: var(--accent); font-weight: 700; background: rgba(245, 158, 11, 0.15); padding: 1px 5px; border-radius: 3px; white-space: nowrap;">⚡ ${formatBytes(t.speed_bytes_per_sec)}/s</span>` : ''}
${(t.paranoid || t.verified_files > 0 || t.last_hash) ? `<span class="task-queue-badge" style="background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); font-size: 8.5px; padding: 1px 4px; white-space: nowrap;">🛡️ SHA-256 ${t.verified_files ? `(${t.verified_files} OK)` : ''}</span>` : ''}
</div>
<span class="task-queue-badge ${badgeClass}">${escapeHtml(t.status)}</span>
</div>
<div class="task-queue-sub" title="${escapeHtml(safeSrc)} ➔ ${escapeHtml(safeDest)}">${escapeHtml(safeSrc)} ➔ ${escapeHtml(safeDest)}</div>
${t.last_hash ? `<div style="font-size: 9px; color: #a3e635; font-family: var(--font-mono); margin-top: 1px; word-break: break-all;">✓ ${escapeHtml(t.last_hash)}</div>` : ''}
${isRunning || isPaused ? `
<div style="height: 3px; background: rgba(255,255,255,0.06); border-radius: 2px; margin-top: 3px; overflow: hidden;">
<div style="height: 100%; width: ${pct}%; background: var(--accent);"></div>
</div>
` : ''}
${safeErr ? `
<div class="task-queue-error-box" onclick="copyTextToClipboard(decodeURIComponent('${encodeURIComponent(safeErr)}'), 'Fault message copied to clipboard!')" title="Click to copy full fault message">
<i data-lucide="alert-circle" style="width: 12px; height: 12px; flex-shrink: 0; margin-top: 2px; color: var(--danger);"></i>
<span style="flex: 1; word-break: break-all; font-family: monospace;">${escapeHtml(safeErr)}</span>
<span style="font-size: 9px; opacity: 0.9; text-decoration: underline; white-space: nowrap;">📋 Copy</span>
</div>
` : ''}
</div>
<div style="display: flex; gap: 4px; align-items: center; flex-shrink: 0;">
${isRunning ? `
<button class="btn btn-sm btn-icon" onclick="pauseTask('${t.id}')" title="Pause"><i data-lucide="pause" style="width: 11px; height: 11px;"></i></button>
<button class="btn btn-sm btn-icon btn-danger" onclick="cancelTask('${t.id}')" title="Cancel"><i data-lucide="x" style="width: 11px; height: 11px;"></i></button>
` : (isPaused ? `
<button class="btn btn-sm btn-icon" onclick="resumeTask('${t.id}')" title="Resume"><i data-lucide="play" style="width: 11px; height: 11px;"></i></button>
<button class="btn btn-sm btn-icon btn-danger" onclick="cancelTask('${t.id}')" title="Cancel"><i data-lucide="x" style="width: 11px; height: 11px;"></i></button>
` : (t.status === 'failed' ? `
<button class="btn btn-sm btn-icon btn-danger" onclick="copyTextToClipboard(decodeURIComponent('${encodeURIComponent(safeErr)}'), 'Fault message copied!')" title="Copy Fault Message"><i data-lucide="copy" style="width: 11px; height: 11px;"></i></button>
` : '✓'))}
</div>
</div>
`;
}).join('');
}
}
// 5. Diagnostic Log Stream
const logConsole = document.getElementById('task-log-console');
if (logConsole) {
const allLogs = [];
list.forEach(t => {
if (t.log_entries && t.log_entries.length > 0) {
allLogs.push(`--- [Job: ${sanitizeCredentials(t.name)}] ---`);
t.log_entries.forEach(l => allLogs.push(sanitizeCredentials(l)));
}
});
if (allLogs.length === 0) {
logConsole.textContent = '// Brum Diagnostic Transfer Log...\n// No events logged yet.';
} else {
const text = allLogs.join('\n');
if (logConsole.textContent !== text) {
logConsole.textContent = text;
const logContainer = document.getElementById('task-log-container');
if (logContainer) logContainer.scrollTop = logContainer.scrollHeight;
}
}
}
if (window.lucide) lucide.createIcons();
}
async function pauseTask(id) {
if (id && (id.startsWith('xnode_') || id.startsWith('client_') || id.startsWith('opt_'))) {
const task = lastKnownTasksList.find(t => t.id === id);
if (task) {
task.status = 'paused';
task.speed_bytes_per_sec = 0;
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
return;
}
const task = lastKnownTasksList.find(t => t.id === id);
const endpoint = (task && task.targetEndpoint) ? task.targetEndpoint : '';
const headers = (task && task.targetHeaders) ? task.targetHeaders : { 'Authorization': `Bearer ${App.token}` };
try {
await fetch(`${endpoint}/api/tasks/${id}/pause`, {
method: 'POST',
headers: headers
});
} catch (_) {}
pollTasks();
}
async function resumeTask(id) {
if (id && (id.startsWith('xnode_') || id.startsWith('client_') || id.startsWith('opt_'))) {
const task = lastKnownTasksList.find(t => t.id === id);
if (task) {
task.status = 'running';
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
return;
}
const task = lastKnownTasksList.find(t => t.id === id);
const endpoint = (task && task.targetEndpoint) ? task.targetEndpoint : '';
const headers = (task && task.targetHeaders) ? task.targetHeaders : { 'Authorization': `Bearer ${App.token}` };
try {
await fetch(`${endpoint}/api/tasks/${id}/resume`, {
method: 'POST',
headers: headers
});
} catch (_) {}
pollTasks();
}
async function cancelTask(id) {
if (id && (id.startsWith('xnode_') || id.startsWith('client_') || id.startsWith('opt_'))) {
const task = lastKnownTasksList.find(t => t.id === id);
if (task) {
task.status = 'cancelled';
task.speed_bytes_per_sec = 0;
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
}
return;
}
const task = lastKnownTasksList.find(t => t.id === id);
const endpoint = (task && task.targetEndpoint) ? task.targetEndpoint : '';
const headers = (task && task.targetHeaders) ? task.targetHeaders : { 'Authorization': `Bearer ${App.token}` };
try {
await fetch(`${endpoint}/api/tasks/${id}/cancel`, {
method: 'POST',
headers: headers
});
} catch (_) {}
pollTasks();
}
async function pauseAllTasks() {
const running = lastKnownTasksList.filter(t => t.status === 'running');
for (const t of running) {
await pauseTask(t.id);
}
}
async function resumeAllTasks() {
const paused = lastKnownTasksList.filter(t => t.status === 'paused');
for (const t of paused) {
await resumeTask(t.id);
}
}
async function clearCompletedTasks() {
lastKnownTasksList = lastKnownTasksList.filter(t => t.status === 'running' || t.status === 'paused');
updateTasksPillState(lastKnownTasksList);
renderFloatingTaskManager(lastKnownTasksList);
renderDockedTasksPanes(lastKnownTasksList);
try {
await fetch('/api/tasks/clear-completed', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` }
});
} catch (_) {}
pollTasks();
}
function copyTaskLogs() {
const consoleEl = document.getElementById('task-log-console');
if (consoleEl) {
copyTextToClipboard(consoleEl.textContent, 'Transfer diagnostic log copied to clipboard!');
}
}
// ---------------- RICH IMAGE VIEWER ----------------
let currentImageIndex = 0;
let currentImageList = [];
let imgZoom = 1;
let imgRotation = 0;
let imgFlipH = 1;
let imgFlipV = 1;
let currentLoadingImageId = 0;
const imageViewerBlobCache = new Map(); // path -> { blobUrl, width, height }
const MAX_IMAGE_BLOB_CACHE = 80;
function isImageExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tiff'].includes(ext);
}
async function getImageBlobUrl(path, paneIndex = null) {
if (imageViewerBlobCache.has(path)) {
const cached = imageViewerBlobCache.get(path);
imageViewerBlobCache.delete(path);
imageViewerBlobCache.set(path, cached);
return cached.blobUrl;
}
let blobUrl;
if (path.startsWith('client://')) {
const handle = await resolveClientFileHandle(path, false);
if (!handle) throw new Error('Client local image file not found');
const file = await handle.getFile();
blobUrl = URL.createObjectURL(file);
} else {
const pIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : currentImageViewerPaneIndex;
const url = getDownloadUrl(path, true, pIdx);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
blobUrl = URL.createObjectURL(blob);
}
if (imageViewerBlobCache.size >= MAX_IMAGE_BLOB_CACHE) {
const oldestKey = imageViewerBlobCache.keys().next().value;
const oldVal = imageViewerBlobCache.get(oldestKey);
if (oldVal && oldVal.blobUrl) {
try { URL.revokeObjectURL(oldVal.blobUrl); } catch (e) {}
}
imageViewerBlobCache.delete(oldestKey);
}
imageViewerBlobCache.set(path, { blobUrl });
return blobUrl;
}
let imgPanX = 0;
let imgPanY = 0;
let imgIsDragging = false;
let imgDragStartX = 0;
let lastImageWheelTime = 0;
let imgWheelMode = localStorage.getItem('cd_img_wheel_mode') || 'browse';
function toggleImgWheelMode() {
imgWheelMode = imgWheelMode === 'browse' ? 'zoom' : 'browse';
localStorage.setItem('cd_img_wheel_mode', imgWheelMode);
updateImgWheelModeButton();
showToast(imgWheelMode === 'browse' ? 'Mouse wheel: Browse next/prev images (Ctrl+Wheel to Zoom)' : 'Mouse wheel: Direct zoom in/out', 'info');
}
function updateImgWheelModeButton() {
const btn = document.getElementById('btn-img-wheel-mode');
if (btn) {
btn.style.color = imgWheelMode === 'zoom' ? 'var(--accent)' : '';
btn.title = `Mouse Wheel Mode: ${imgWheelMode === 'browse' ? 'Browse Images (Click to switch to Direct Zoom)' : 'Direct Zoom (Click to switch to Browse)'}`;
}
}
function setupImageViewerEvents() {
const viewport = document.getElementById('img-viewer-viewport');
if (!viewport || viewport._wheelAttached) return;
viewport._wheelAttached = true;
updateImgWheelModeButton();
// Mouse wheel listener: Scroll to cycle images, pan when zoomed, or direct zoom
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const isDirectZoom = imgWheelMode === 'zoom';
if (e.ctrlKey || e.metaKey || isDirectZoom) {
const delta = e.deltaY < 0 ? 0.15 : -0.15;
zoomImage(delta);
} else if (imgZoom > 1) {
// Pan image when zoomed in
if (e.shiftKey) {
imgPanX -= e.deltaY * 0.8;
} else {
imgPanY -= e.deltaY * 0.8;
if (e.deltaX) imgPanX -= e.deltaX * 0.8;
}
applyImageTransform();
} else {
// Browse next / previous images when at 1:1 scale
const now = Date.now();
if (now - lastImageWheelTime > 160) {
lastImageWheelTime = now;
if (e.deltaY > 0 || e.deltaX > 0) {
navImageViewer(1);
} else if (e.deltaY < 0 || e.deltaX < 0) {
navImageViewer(-1);
}
}
}
}, { passive: false });
// Double-click to toggle fit-to-screen vs 2x zoom
viewport.addEventListener('dblclick', (e) => {
e.preventDefault();
if (imgZoom === 1) {
imgZoom = 2;
} else {
imgZoom = 1;
imgPanX = 0;
imgPanY = 0;
}
applyImageTransform();
});
// Pan image when zoomed in
viewport.addEventListener('mousedown', (e) => {
if (imgZoom > 1 && e.button === 0) {
imgIsDragging = true;
imgDragStartX = e.clientX - imgPanX;
imgDragStartY = e.clientY - imgPanY;
viewport.style.cursor = 'grabbing';
}
});
window.addEventListener('mousemove', (e) => {
if (imgIsDragging) {
imgPanX = e.clientX - imgDragStartX;
imgPanY = e.clientY - imgDragStartY;
applyImageTransform();
}
});
window.addEventListener('mouseup', () => {
if (imgIsDragging) {
imgIsDragging = false;
const vp = document.getElementById('img-viewer-viewport');
if (vp) vp.style.cursor = imgZoom > 1 ? 'grab' : 'default';
}
});
}
function preloadAdjacentImages() {
if (!currentImageList || currentImageList.length <= 1) return;
const total = currentImageList.length;
const indices = [
(currentImageIndex + 1) % total,
(currentImageIndex - 1 + total) % total,
(currentImageIndex + 2) % total,
(currentImageIndex - 2 + total) % total,
(currentImageIndex + 3) % total,
(currentImageIndex - 3 + total) % total,
];
indices.forEach(idx => {
const p = currentImageList[idx];
if (p && !imageViewerBlobCache.has(p)) {
getImageBlobUrl(p).catch(() => {});
}
});
}
let imageViewerDragInitialized = false;
function setupImageViewerWindowResizers(win) {
const handles = {
top: document.getElementById('img-viewer-resize-top'),
bottom: document.getElementById('img-viewer-resize-bottom'),
left: document.getElementById('img-viewer-resize-left'),
right: document.getElementById('img-viewer-resize-right'),
corner: document.getElementById('img-viewer-resize-corner')
};
let resizeMode = null;
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
if (win.classList.contains('fullscreen')) return;
e.preventDefault();
e.stopPropagation();
resizeMode = mode;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
const onMouseMove = (moveEvent) => {
if (!resizeMode) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const minW = 380;
const minH = 260;
if (resizeMode === 'right' || resizeMode === 'corner') {
win.style.width = `${Math.max(minW, startW + dx)}px`;
}
if (resizeMode === 'bottom' || resizeMode === 'corner') {
win.style.height = `${Math.max(minH, startH + dy)}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, startW - dx);
if (newW > minW) {
win.style.width = `${newW}px`;
win.style.left = `${startLeft + dx}px`;
}
}
if (resizeMode === 'top') {
const newH = Math.max(minH, startH - dy);
if (newH > minH) {
win.style.height = `${newH}px`;
win.style.top = `${startTop + dy}px`;
}
}
};
const onMouseUp = () => {
if (resizeMode) {
resizeMode = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
if (win.style.width) localStorage.setItem('cd_img_viewer_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_img_viewer_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_img_viewer_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_img_viewer_y', parseInt(win.style.top, 10));
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
});
}
function initImageViewerDrag() {
if (imageViewerDragInitialized) return;
imageViewerDragInitialized = true;
const modal = document.getElementById('image-viewer-modal');
const win = document.getElementById('image-viewer-box');
const toolbar = document.getElementById('image-viewer-toolbar');
if (!win || !toolbar) return;
const savedLeft = localStorage.getItem('cd_img_viewer_x');
const savedTop = localStorage.getItem('cd_img_viewer_y');
const savedWidth = localStorage.getItem('cd_img_viewer_w');
const savedHeight = localStorage.getItem('cd_img_viewer_h');
const savedFloating = localStorage.getItem('cd_img_viewer_floating') === 'true';
if (savedFloating && modal) {
modal.classList.add('floating-mode');
}
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 300, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 200, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(380, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(260, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
toolbar.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('a') || e.target.closest('input') || e.target.closest('.win-resize-handle') || win.classList.contains('fullscreen')) return;
if (modal && !modal.classList.contains('floating-mode')) {
toggleImageViewerFloating(true);
}
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, winStartX + dx));
const newTop = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem('cd_img_viewer_x', win.offsetLeft);
localStorage.setItem('cd_img_viewer_y', win.offsetTop);
}
});
setupImageViewerWindowResizers(win);
}
function toggleImageViewerFloating(forceFloating) {
const modal = document.getElementById('image-viewer-modal');
const win = document.getElementById('image-viewer-box');
const btn = document.getElementById('btn-img-viewer-float');
if (!modal || !win) return;
const makeFloating = forceFloating !== undefined ? forceFloating : !modal.classList.contains('floating-mode');
modal.classList.toggle('floating-mode', makeFloating);
localStorage.setItem('cd_img_viewer_floating', makeFloating ? 'true' : 'false');
if (makeFloating) {
if (!win.style.left || !win.style.top) {
const rect = win.getBoundingClientRect();
win.style.left = `${Math.max(20, (window.innerWidth - (rect.width || 750)) / 2)}px`;
win.style.top = `${Math.max(50, (window.innerHeight - (rect.height || 550)) / 2)}px`;
}
if (btn) btn.innerHTML = '<i data-lucide="maximize"></i>';
showToast('Image Viewer is now in Floating Window Mode', 'info');
} else {
win.style.left = '';
win.style.top = '';
if (btn) btn.innerHTML = '<i data-lucide="picture-in-picture"></i>';
}
if (window.lucide) lucide.createIcons();
}
let currentImageViewerPaneIndex = null;
function openImageViewer(filePath, paneIndex = null) {
currentImageViewerPaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[currentImageViewerPaneIndex];
currentImageList = (pane && pane.entries) ? pane.entries.filter(e => !e.is_dir && isImageExtension(e.name)).map(e => e.path) : [filePath];
if (currentImageList.length === 0) {
currentImageList = [filePath];
}
currentImageIndex = currentImageList.indexOf(filePath);
if (currentImageIndex === -1) currentImageIndex = 0;
initImageViewerDrag();
setupImageViewerEvents();
loadImageToViewer(currentImageList[currentImageIndex], currentImageViewerPaneIndex);
showModal('image-viewer-modal');
}
async function loadImageToViewer(path, paneIndex = null) {
const pIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : currentImageViewerPaneIndex;
const imgEl = document.getElementById('img-viewer-element');
const titleEl = document.getElementById('img-viewer-title');
const metaEl = document.getElementById('img-viewer-meta');
const counterEl = document.getElementById('img-viewer-counter');
resetImageTransform();
const fileName = path.split(/[\\/]/).pop() || path;
if (titleEl) titleEl.textContent = fileName;
if (counterEl) counterEl.textContent = `${currentImageIndex + 1} / ${currentImageList.length}`;
const loadId = ++currentLoadingImageId;
const rawDlUrl = getDownloadUrl(path, false, pIdx);
const dlLink = document.getElementById('img-viewer-download-link');
if (dlLink) dlLink.href = rawDlUrl;
if (!imgEl) return;
// 1. Instant RAM Cache Hit (0ms)
if (imageViewerBlobCache.has(path)) {
const cached = imageViewerBlobCache.get(path);
imgEl.classList.remove('loading');
imgEl.src = cached.blobUrl;
if (metaEl && cached.width && cached.height) {
metaEl.textContent = `(${cached.width} × ${cached.height} px)`;
} else if (metaEl) {
metaEl.textContent = '';
}
preloadAdjacentImages();
return;
}
// 2. Fetch in background network thread as Blob to bypass single-threaded WebKitGTK HTTP stalls
imgEl.classList.add('loading');
try {
const blobUrl = await getImageBlobUrl(path);
if (loadId !== currentLoadingImageId) return;
imgEl.src = blobUrl;
imgEl.onload = () => {
if (loadId !== currentLoadingImageId) return;
imgEl.classList.remove('loading');
const entry = imageViewerBlobCache.get(path);
if (entry) {
entry.width = imgEl.naturalWidth;
entry.height = imgEl.naturalHeight;
}
if (metaEl) metaEl.textContent = `(${imgEl.naturalWidth} × ${imgEl.naturalHeight} px)`;
preloadAdjacentImages();
};
imgEl.onerror = () => {
if (loadId !== currentLoadingImageId) return;
imgEl.classList.remove('loading');
if (metaEl) metaEl.textContent = '(Preview unavailable)';
};
} catch (err) {
if (loadId !== currentLoadingImageId) return;
// Fallback direct URL if blob creation failed
imgEl.src = getDownloadUrl(path, true);
imgEl.onload = () => {
if (loadId !== currentLoadingImageId) return;
imgEl.classList.remove('loading');
if (metaEl) metaEl.textContent = `(${imgEl.naturalWidth} × ${imgEl.naturalHeight} px)`;
preloadAdjacentImages();
};
imgEl.onerror = () => {
if (loadId !== currentLoadingImageId) return;
imgEl.classList.remove('loading');
if (metaEl) metaEl.textContent = '(Preview unavailable)';
};
}
}
function navImageViewer(dir) {
if (currentImageList.length <= 1) return;
currentImageIndex = (currentImageIndex + dir + currentImageList.length) % currentImageList.length;
loadImageToViewer(currentImageList[currentImageIndex]);
}
function zoomImage(delta) {
imgZoom = Math.max(0.2, Math.min(6, parseFloat((imgZoom + delta).toFixed(2))));
if (imgZoom <= 1) {
imgPanX = 0;
imgPanY = 0;
}
applyImageTransform();
}
function rotateImage(deg) {
imgRotation = (imgRotation + deg) % 360;
applyImageTransform();
}
function flipImage(axis) {
if (axis === 'h') imgFlipH *= -1;
if (axis === 'v') imgFlipV *= -1;
applyImageTransform();
}
function resetImageTransform() {
imgZoom = 1;
imgRotation = 0;
imgFlipH = 1;
imgFlipV = 1;
imgPanX = 0;
imgPanY = 0;
applyImageTransform();
}
function applyImageTransform() {
const imgEl = document.getElementById('img-viewer-element');
const viewport = document.getElementById('img-viewer-viewport');
if (imgEl) {
imgEl.style.transform = `translate(${imgPanX}px, ${imgPanY}px) scale(${imgZoom}) rotate(${imgRotation}deg) scaleX(${imgFlipH}) scaleY(${imgFlipV})`;
}
if (viewport) {
viewport.style.cursor = imgZoom > 1 ? (imgIsDragging ? 'grabbing' : 'grab') : 'default';
}
}
function downloadCurrentImage() {
const path = currentImageList[currentImageIndex];
if (path) {
window.open(getDownloadUrl(path, false), '_blank');
}
}
async function imageViewerDeleteCurrent() {
if (!currentImageList || currentImageList.length === 0) return;
const filePath = currentImageList[currentImageIndex];
if (!filePath) return;
const useTrash = App.trashEnabled !== false;
const customTrash = App.customTrashDir || null;
const trashName = getTrashDisplayName(false);
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const confirmed = await showConfirmDialog({
title: useTrash ? `Move to ${trashName} Confirmation` : 'Permanent Deletion Confirmation',
subtitle: useTrash ? `Move image to ${trashName}` : 'Permanently Delete image',
message: useTrash
? `Move '${fileName}' to ${trashName}?`
: `Permanently delete '${fileName}'? This action cannot be undone!`,
items: [fileName],
icon: 'trash-2',
type: 'danger',
confirmText: useTrash ? `Move to ${trashName} (F8)` : 'Permanently Delete (F8)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
const authPath = resolveAuthUri(filePath);
const resp = await fetch('/api/fs/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
paths: [authPath],
use_trash: useTrash,
custom_trash_dir: customTrash,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (resp.ok) {
showToast(useTrash ? `Moved "${fileName}" to ${trashName}` : `Permanently deleted "${fileName}"`, 'success');
const oldEntry = imageViewerBlobCache.get(filePath);
if (oldEntry && oldEntry.blobUrl) {
try { URL.revokeObjectURL(oldEntry.blobUrl); } catch (e) {}
}
imageViewerBlobCache.delete(filePath);
currentImageList.splice(currentImageIndex, 1);
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected) p.selected.delete(filePath);
});
}
refreshAllPanes();
if (currentImageList.length > 0) {
currentImageIndex = Math.min(currentImageIndex, currentImageList.length - 1);
loadImageToViewer(currentImageList[currentImageIndex]);
} else {
closeModal('image-viewer-modal');
}
} else {
showToast(`Delete failed: ${sanitizeCredentials(await resp.text())}`, 'error');
}
} catch (err) {
showToast(`Delete error: ${sanitizeCredentials(String(err))}`, 'error');
}
}
}
async function imageViewerMoveCurrent() {
if (!currentImageList || currentImageList.length === 0) return;
const filePath = currentImageList[currentImageIndex];
if (!filePath) return;
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const targetPane = App.panes[targetIdx];
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const destDir = targetPane ? targetPane.path : '/';
const confirmed = await showConfirmDialog({
title: 'Move Image Confirmation',
subtitle: `Move to ${sanitizeCredentials(destDir)}`,
message: `Move '${fileName}' to opposite panel directory?`,
items: [`Source: ${sanitizeCredentials(filePath)}`, `Target: ${sanitizeCredentials(destDir)}`],
icon: 'folder-input',
type: 'warning',
confirmText: 'Move File (F6)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
executeTransfer('move', [filePath], destDir, targetIdx, App.activePaneIndex);
currentImageList.splice(currentImageIndex, 1);
if (currentImageList.length > 0) {
currentImageIndex = Math.min(currentImageIndex, currentImageList.length - 1);
loadImageToViewer(currentImageList[currentImageIndex]);
} else {
closeModal('image-viewer-modal');
}
} catch (err) {
showToast(`Move error: ${err}`, 'error');
}
}
}
async function imageViewerCopyCurrent() {
if (!currentImageList || currentImageList.length === 0) return;
const filePath = currentImageList[currentImageIndex];
if (!filePath) return;
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const targetPane = App.panes[targetIdx];
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const destDir = targetPane ? targetPane.path : '/';
const confirmed = await showConfirmDialog({
title: 'Copy Image Confirmation',
subtitle: `Copy to ${sanitizeCredentials(destDir)}`,
message: `Copy '${fileName}' to opposite panel directory?`,
items: [`Source: ${sanitizeCredentials(filePath)}`, `Target: ${sanitizeCredentials(destDir)}`],
icon: 'copy',
type: 'info',
confirmText: 'Copy File (F5)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
executeTransfer('copy', [filePath], destDir, targetIdx, App.activePaneIndex);
} catch (err) {
showToast(`Copy error: ${err}`, 'error');
}
}
}
async function imageViewerRenameCurrent() {
if (!currentImageList || currentImageList.length === 0) return;
const filePath = currentImageList[currentImageIndex];
if (!filePath) return;
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const parts = filePath.split(/[\\/]/);
parts.pop();
const parentDir = parts.join('/') || '/';
const input = document.getElementById('rename-input');
if (!input) return;
input.value = fileName;
showModal('rename-modal');
setTimeout(() => {
input.focus();
const lastDot = fileName.lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
}, 40);
const executeRename = async () => {
const newName = input.value.trim();
if (!newName || newName === fileName) {
closeModal('rename-modal');
return;
}
const toPath = joinItemPath(parentDir, newName);
closeModal('rename-modal');
try {
const resp = await fetch('/api/fs/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ from: resolveAuthUri(filePath), to: resolveAuthUri(toPath) })
});
if (resp.ok) {
showToast(`Renamed to "${newName}"`, 'success');
const oldEntry = imageViewerBlobCache.get(filePath);
if (oldEntry && oldEntry.blobUrl) {
try { URL.revokeObjectURL(oldEntry.blobUrl); } catch (e) {}
}
imageViewerBlobCache.delete(filePath);
currentImageList[currentImageIndex] = toPath;
loadImageToViewer(toPath);
refreshAllPanes();
} else {
showToast('Rename failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Rename failed: ' + err.message, 'error');
}
};
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeRename();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal('rename-modal');
}
};
const confirmBtn = document.getElementById('btn-confirm-rename');
if (confirmBtn) confirmBtn.onclick = executeRename;
}
// ---------------- UNIVERSAL DOCUMENT & PDF READERS ----------------
let currentDocViewerPath = '';
let currentDocViewerRawText = '';
let currentDocViewerMode = 'rendered'; // 'rendered' or 'raw'
let docCsvRows = [];
let docCsvHeaders = [];
let docCsvFiltered = [];
let docPdfZoom = 100;
let docPdfRotation = 0;
function isPdfExtension(filename) {
if (!filename) return false;
return filename.split('.').pop().toLowerCase() === 'pdf';
}
function isDocumentExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['pdf', 'md', 'markdown', 'rst', 'csv', 'tsv', 'tab', 'json', 'html', 'htm', 'xml', 'svg', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'doc', 'xls', 'ppt', 'log', 'txt', 'rtf'].includes(ext);
}
function isCadOr3dExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['stl', 'obj', 'gltf', 'glb', '3mf', 'step', 'stp', 'iges', 'igs', 'dxf', 'ply', 'off'].includes(ext);
}
function isAudioExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma', 'opus', 'aiff', 'alac', 'mid', 'midi', 'mod', 'xm', 's3m', 'it', 'sid', 'nsf', 'vgm', 'spc'].includes(ext);
}
function isTrackerOrMidiExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['mid', 'midi', 'mod', 'xm', 's3m', 'it'].includes(ext);
}
function isVideoExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['mp4', 'webm', 'mkv', 'avi', 'mov', 'm4v', 'ogv', 'mpg', 'mpeg', 'wmv', 'flv', 'ts', 'm2ts', 'vob', '3gp', 'divx', 'asf'].includes(ext);
}
function isComicBookExtension(filename) {
if (!filename) return false;
const ext = filename.split('.').pop().toLowerCase();
return ['cbz', 'cbr', 'epub'].includes(ext);
}
function isArchiveFile(filename) {
if (!filename) return false;
const lower = filename.toLowerCase();
return lower.endsWith('.zip') ||
lower.endsWith('.grr') ||
lower.endsWith('.cbz') ||
lower.endsWith('.epub') ||
lower.endsWith('.tar.gz') ||
lower.endsWith('.tgz') ||
lower.endsWith('.tar.bz2') ||
lower.endsWith('.tbz2') ||
lower.endsWith('.tar.xz') ||
lower.endsWith('.txz') ||
lower.endsWith('.tar') ||
lower.endsWith('.iso') ||
lower.endsWith('.udf') ||
lower.endsWith('.img') ||
lower.endsWith('.raw') ||
lower.endsWith('.dd') ||
lower.endsWith('.vhd') ||
lower.endsWith('.squashfs') ||
lower.endsWith('.snap') ||
lower.endsWith('.appimage');
}
function isVaultFile(filename) {
if (!filename) return false;
const lower = filename.toLowerCase();
return lower.endsWith('.cdvault') || lower.endsWith('.cdv') || lower.endsWith('.vault');
}
async function handleVaultOpen(vaultPath, paneIndex = App.activePaneIndex) {
try {
const res = await fetch(`/api/vault/status?path=${encodeURIComponent(vaultPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
const data = await res.json();
if (data.unlocked) {
loadPaneDirectory(paneIndex, `vault://${vaultPath}#`);
} else {
openUnlockVaultModal(vaultPath, paneIndex);
}
} catch (e) {
openUnlockVaultModal(vaultPath, paneIndex);
}
}
function openCreateVaultModal(prefillPath = '') {
const pane = App.panes[App.activePaneIndex];
const basePath = prefillPath || pane?.path || '/';
const defaultVaultPath = basePath.endsWith('/') ? `${basePath}My_Encrypted_Vault.cdvault` : `${basePath}/My_Encrypted_Vault.cdvault`;
const pathInput = document.getElementById('create-vault-path');
if (pathInput) pathInput.value = defaultVaultPath;
const passInput = document.getElementById('create-vault-pass');
if (passInput) passInput.value = '';
const confirmInput = document.getElementById('create-vault-pass-confirm');
if (confirmInput) confirmInput.value = '';
openModal('create-vault-modal');
setTimeout(() => { if (passInput) passInput.focus(); }, 100);
}
async function executeCreateVault() {
const path = document.getElementById('create-vault-path').value.trim();
const pass = document.getElementById('create-vault-pass').value;
const passConfirm = document.getElementById('create-vault-pass-confirm').value;
if (!path) {
showToast('Please specify a vault file path', 'error');
return;
}
if (!pass) {
showToast('Please enter a master password', 'error');
return;
}
if (pass !== passConfirm) {
showToast('Master passwords do not match', 'error');
return;
}
const btn = document.getElementById('btn-confirm-create-vault');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> Creating Vault...';
if (window.lucide) lucide.createIcons();
}
try {
const res = await fetch('/api/vault/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path, password: pass })
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to create vault');
}
closeModal('create-vault-modal');
showToast(`🔒 Encrypted vault created: ${path.split('/').pop()}`, 'success');
refreshAllPanes();
// Prompt to unlock immediately
setTimeout(() => {
openUnlockVaultModal(path, App.activePaneIndex);
}, 300);
} catch (e) {
showToast(e.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="lock"></i> Create Encrypted Vault';
if (window.lucide) lucide.createIcons();
}
}
}
let currentUnlockingVaultPane = 0;
function openUnlockVaultModal(vaultPath, paneIndex = App.activePaneIndex) {
currentUnlockingVaultPane = paneIndex;
const pathInput = document.getElementById('unlock-vault-path');
if (pathInput) pathInput.value = vaultPath;
const passInput = document.getElementById('unlock-vault-pass');
if (passInput) passInput.value = '';
const errBox = document.getElementById('unlock-vault-err');
if (errBox) {
errBox.style.display = 'none';
errBox.textContent = '';
}
openModal('unlock-vault-modal');
setTimeout(() => { if (passInput) passInput.focus(); }, 100);
}
async function executeUnlockVault() {
const path = document.getElementById('unlock-vault-path').value.trim();
const password = document.getElementById('unlock-vault-pass').value;
const autoLockSecs = parseInt(document.getElementById('unlock-vault-autolock').value, 10) || 900;
const errBox = document.getElementById('unlock-vault-err');
if (!password) {
if (errBox) {
errBox.textContent = 'Please enter the master password';
errBox.style.display = 'block';
}
return;
}
const btn = document.getElementById('btn-confirm-unlock-vault');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> Deriving Key (Argon2id)...';
if (window.lucide) lucide.createIcons();
}
try {
const res = await fetch('/api/vault/unlock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ path, password, auto_lock_secs: autoLockSecs })
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Invalid master password');
}
closeModal('unlock-vault-modal');
showToast(`🔓 Vault unlocked. Decrypting transparently in RAM.`, 'success');
loadPaneDirectory(currentUnlockingVaultPane, `vault://${path}#`);
} catch (e) {
if (errBox) {
errBox.textContent = e.message;
errBox.style.display = 'block';
} else {
showToast(e.message, 'error');
}
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="key"></i> Unlock Vault';
if (window.lucide) lucide.createIcons();
}
}
}
function openFileByType(entry, paneIndex) {
const pane = App.panes[paneIndex];
if (isVaultFile(entry.name)) {
handleVaultOpen(entry.path, paneIndex);
} else if (entry.is_dir || entry.is_archive || isArchiveFile(entry.name)) {
if (pane && pane.isBranchView) {
pane.isBranchView = false;
pane.isBranchTruncated = false;
}
const targetPath = (entry.is_archive || isArchiveFile(entry.name)) && !entry.path.startsWith('archive://')
? `archive://${entry.path}#`
: entry.path;
loadPaneDirectory(paneIndex, targetPath);
} else if (isPdfExtension(entry.name) || isDocumentExtension(entry.name)) {
openDocumentViewer(entry.path, paneIndex);
} else if (isCadOr3dExtension(entry.name)) {
openCadStudio(entry.path, paneIndex);
} else if (isAudioExtension(entry.name)) {
openSoundDog(entry.path, paneIndex);
} else if (isVideoExtension(entry.name)) {
openMediaPlayer(entry.path, 'video', paneIndex);
} else if (isComicBookExtension(entry.name)) {
openBookReader(entry.path);
} else if (isImageExtension(entry.name)) {
openImageViewer(entry.path, paneIndex);
} else {
openEditorWithFile(entry.path, paneIndex);
}
}
// PDF Reader backward compatibility alias
function openPdfViewer(filePath) {
openDocumentViewer(filePath);
}
let docViewerDragInitialized = false;
let docViewerTailFollow = false;
let docViewerTailLines = localStorage.getItem('cd_tail_lines') || '100';
let docViewerTailTimer = null;
let docViewerWordWrap = true;
function setupDocViewerWindowResizers(win) {
const handles = {
top: document.getElementById('doc-viewer-resize-top'),
bottom: document.getElementById('doc-viewer-resize-bottom'),
left: document.getElementById('doc-viewer-resize-left'),
right: document.getElementById('doc-viewer-resize-right'),
corner: document.getElementById('doc-viewer-resize-corner')
};
let resizeMode = null;
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
if (win.classList.contains('fullscreen')) return;
e.preventDefault();
e.stopPropagation();
resizeMode = mode;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
const onMouseMove = (moveEvent) => {
if (!resizeMode) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const minW = 400;
const minH = 250;
if (resizeMode === 'right' || resizeMode === 'corner') {
win.style.width = `${Math.max(minW, startW + dx)}px`;
}
if (resizeMode === 'bottom' || resizeMode === 'corner') {
win.style.height = `${Math.max(minH, startH + dy)}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, startW - dx);
if (newW > minW) {
win.style.width = `${newW}px`;
win.style.left = `${startLeft + dx}px`;
}
}
if (resizeMode === 'top') {
const newH = Math.max(minH, startH - dy);
if (newH > minH) {
win.style.height = `${newH}px`;
win.style.top = `${startTop + dy}px`;
}
}
};
const onMouseUp = () => {
if (resizeMode) {
resizeMode = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
if (win.style.width) localStorage.setItem('cd_doc_viewer_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_doc_viewer_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_doc_viewer_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_doc_viewer_y', parseInt(win.style.top, 10));
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
});
}
function initDocViewerDrag() {
if (docViewerDragInitialized) return;
docViewerDragInitialized = true;
const modal = document.getElementById('doc-viewer-modal');
const win = document.getElementById('doc-viewer-box');
const header = document.getElementById('doc-viewer-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_doc_viewer_x');
const savedTop = localStorage.getItem('cd_doc_viewer_y');
const savedWidth = localStorage.getItem('cd_doc_viewer_w');
const savedHeight = localStorage.getItem('cd_doc_viewer_h');
const savedFloating = localStorage.getItem('cd_doc_viewer_floating') === 'true';
if (savedFloating && modal) {
modal.classList.add('floating-mode');
}
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 300, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 200, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(400, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(250, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('a') || e.target.closest('input') || e.target.closest('select') || e.target.closest('.win-resize-handle') || win.classList.contains('fullscreen')) return;
if (modal && !modal.classList.contains('floating-mode')) {
toggleDocViewerFloating(true);
}
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, winStartX + dx));
const newTop = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem('cd_doc_viewer_x', win.offsetLeft);
localStorage.setItem('cd_doc_viewer_y', win.offsetTop);
}
});
setupDocViewerWindowResizers(win);
}
function toggleDocViewerFloating(forceFloating) {
const modal = document.getElementById('doc-viewer-modal');
const win = document.getElementById('doc-viewer-box');
const btn = document.getElementById('btn-doc-viewer-float');
if (!modal || !win) return;
const makeFloating = forceFloating !== undefined ? forceFloating : !modal.classList.contains('floating-mode');
modal.classList.toggle('floating-mode', makeFloating);
localStorage.setItem('cd_doc_viewer_floating', makeFloating ? 'true' : 'false');
if (makeFloating) {
if (!win.style.left || !win.style.top) {
const rect = win.getBoundingClientRect();
win.style.left = `${Math.max(20, (window.innerWidth - (rect.width || 800)) / 2)}px`;
win.style.top = `${Math.max(50, (window.innerHeight - (rect.height || 600)) / 2)}px`;
}
if (btn) btn.innerHTML = '<i data-lucide="maximize"></i>';
showToast('Viewer is now in Floating Window Mode', 'info');
} else {
win.style.left = '';
win.style.top = '';
if (btn) btn.innerHTML = '<i data-lucide="picture-in-picture"></i>';
}
if (window.lucide) lucide.createIcons();
}
function renderTextDocViewerControls() {
const controlsEl = document.getElementById('doc-viewer-controls');
if (!controlsEl) return;
controlsEl.innerHTML = `
<div class="doc-viewer-controls-inner" style="display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;">
<button class="btn btn-sm ${docViewerTailFollow ? 'btn-tail-live' : ''}" id="btn-tail-follow" onclick="toggleTailFollow()" title="Toggle live streaming / tail follow updates" style="height: 28px; padding: 0 8px; font-size: 11px; display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0;">
<i data-lucide="${docViewerTailFollow ? 'radio' : 'activity'}" style="width: 13px; height: 13px;"></i>
<span id="tail-follow-label">${docViewerTailFollow ? 'Following' : 'Follow (tail -f)'}</span>
</button>
<div class="doc-viewer-tail-select-wrap" style="display: flex; align-items: center; gap: 3px; background: var(--bg-dark); border: 1px solid var(--border); border-radius: 4px; padding: 0 6px; height: 28px; box-sizing: border-box; flex-shrink: 0;">
<span style="font-size: 10.5px; color: var(--text-dim); font-family: var(--font-mono); font-weight: 600;">-n</span>
<select id="tail-lines-select" style="background: transparent; border: none; color: var(--text-main); font-size: 11px; height: 26px; outline: none; cursor: pointer; padding: 0;" onchange="changeTailLines(this.value)">
<option value="50" ${docViewerTailLines === '50' ? 'selected' : ''}>50 lines</option>
<option value="100" ${docViewerTailLines === '100' ? 'selected' : ''}>100 lines</option>
<option value="250" ${docViewerTailLines === '250' ? 'selected' : ''}>250 lines</option>
<option value="500" ${docViewerTailLines === '500' ? 'selected' : ''}>500 lines</option>
<option value="1000" ${docViewerTailLines === '1000' ? 'selected' : ''}>1,000 lines</option>
<option value="all" ${docViewerTailLines === 'all' ? 'selected' : ''}>All lines</option>
</select>
</div>
<button class="btn btn-icon btn-sm" onclick="scrollViewerToBottom()" title="Scroll to Bottom (End)" style="height: 28px; width: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="arrow-down" style="width: 13px; height: 13px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="toggleViewerWordWrap()" title="Toggle Word Wrap" style="height: 28px; width: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="wrap-text" style="width: 13px; height: 13px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="refreshDocViewerText()" title="Refresh text" style="height: 28px; width: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="refresh-cw" style="width: 13px; height: 13px;"></i></button>
</div>
`;
if (window.lucide) lucide.createIcons();
}
let currentDocViewerPaneIndex = null;
async function refreshDocViewerText() {
if (!currentDocViewerPath) return;
const contentEl = document.getElementById('doc-text-content');
const metaEl = document.getElementById('doc-viewer-meta');
const ext = currentDocViewerPath.split('.').pop().toLowerCase();
try {
if (currentDocViewerPath.startsWith('client://')) {
const fileHandle = await resolveClientFileHandle(currentDocViewerPath);
const file = await fileHandle.getFile();
currentDocViewerRawText = await file.text();
} else {
const pIdx = (currentDocViewerPaneIndex !== null && currentDocViewerPaneIndex !== undefined) ? currentDocViewerPaneIndex : App.activePaneIndex;
const endpoint = typeof getPaneEndpoint === 'function' ? getPaneEndpoint(pIdx) : '';
const headers = typeof getPaneAuthHeaders === 'function' ? getPaneAuthHeaders(pIdx) : { 'Authorization': `Bearer ${App.token}` };
const resp = await fetch(`${endpoint}/api/fs/read?path=${encodeURIComponent(currentDocViewerPath)}`, {
headers
});
if (resp.ok) {
const data = await resp.json();
currentDocViewerRawText = data.content || '';
}
}
const allLines = currentDocViewerRawText.split('\n');
const totalLineCount = allLines.length;
let displayedText = currentDocViewerRawText;
if (docViewerTailLines !== 'all') {
const n = parseInt(docViewerTailLines, 10);
if (totalLineCount > n) {
displayedText = allLines.slice(-n).join('\n');
}
}
if (contentEl) {
contentEl.textContent = displayedText;
}
if (metaEl) {
const sizeStr = formatBytes(new Blob([currentDocViewerRawText]).size);
const liveIndicator = docViewerTailFollow ? ' • <span style="color: #10b981; font-weight: bold;">🟢 Live (tail -f)</span>' : '';
metaEl.innerHTML = `${ext.toUpperCase()} • ${totalLineCount.toLocaleString()} lines (showing ${docViewerTailLines === 'all' ? 'all' : Math.min(totalLineCount, parseInt(docViewerTailLines, 10))}) • ${sizeStr}${liveIndicator}`;
}
if (docViewerTailFollow) {
scrollViewerToBottom();
}
} catch (e) {
console.error('Tail refresh failed:', e);
}
}
function toggleTailFollow() {
docViewerTailFollow = !docViewerTailFollow;
const btn = document.getElementById('btn-tail-follow');
const label = document.getElementById('tail-follow-label');
if (docViewerTailFollow) {
if (btn) btn.classList.add('btn-tail-live');
if (label) label.textContent = 'Following (tail -f)';
showToast('Live Follow mode active (tail -f)', 'success');
if (docViewerTailTimer) clearInterval(docViewerTailTimer);
docViewerTailTimer = setInterval(refreshDocViewerText, 1200);
refreshDocViewerText();
} else {
if (btn) btn.classList.remove('btn-tail-live');
if (label) label.textContent = 'Follow (tail -f)';
if (docViewerTailTimer) {
clearInterval(docViewerTailTimer);
docViewerTailTimer = null;
}
showToast('Paused live follow mode', 'info');
refreshDocViewerText();
}
if (window.lucide) lucide.createIcons();
}
function changeTailLines(val) {
docViewerTailLines = val;
localStorage.setItem('cd_tail_lines', val);
refreshDocViewerText();
}
function scrollViewerToBottom() {
const container = document.getElementById('doc-view-text');
if (container) {
container.scrollTop = container.scrollHeight;
}
}
function toggleViewerWordWrap() {
const contentEl = document.getElementById('doc-text-content');
if (!contentEl) return;
docViewerWordWrap = !docViewerWordWrap;
contentEl.style.whiteSpace = docViewerWordWrap ? 'pre-wrap' : 'pre';
contentEl.style.wordBreak = docViewerWordWrap ? 'break-word' : 'normal';
showToast(`Word wrap: ${docViewerWordWrap ? 'ON' : 'OFF'}`, 'info');
}
async function openDocumentViewer(filePath, paneIndex = null) {
currentDocViewerPaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
currentDocViewerPath = filePath;
currentDocViewerRawText = '';
currentDocViewerMode = 'rendered';
docPdfZoom = 100;
docPdfRotation = 0;
initDocViewerDrag();
const fileName = filePath.split('/').pop() || filePath;
const ext = fileName.split('.').pop().toLowerCase();
const titleEl = document.getElementById('doc-viewer-title');
const metaEl = document.getElementById('doc-viewer-meta');
const iconEl = document.getElementById('doc-viewer-icon');
const controlsEl = document.getElementById('doc-viewer-controls');
const dlEl = document.getElementById('doc-viewer-download');
const extEl = document.getElementById('doc-viewer-external');
if (titleEl) titleEl.textContent = fileName;
const downloadUrl = getDownloadUrl(filePath, false, currentDocViewerPaneIndex);
const streamUrl = getDownloadUrl(filePath, true, currentDocViewerPaneIndex);
const endpoint = typeof getPaneEndpoint === 'function' ? getPaneEndpoint(currentDocViewerPaneIndex) : '';
const headers = typeof getPaneAuthHeaders === 'function' ? getPaneAuthHeaders(currentDocViewerPaneIndex) : { 'Authorization': `Bearer ${App.token}` };
if (dlEl) {
dlEl.href = downloadUrl;
dlEl.download = fileName;
}
if (extEl) extEl.href = streamUrl;
// Hide all panels initially
['doc-view-pdf', 'doc-view-md', 'doc-view-csv', 'doc-view-web', 'doc-view-text', 'doc-view-office'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// Setup format-specific view and toolbar
if (ext === 'pdf') {
if (metaEl) metaEl.textContent = 'PDF Document • In-Browser Reader';
if (iconEl) iconEl.setAttribute('data-lucide', 'file-text');
if (controlsEl) {
controlsEl.innerHTML = `
<button class="btn btn-icon" onclick="changeDocPdfZoom(-15)" title="Zoom Out (-)"><i data-lucide="zoom-out"></i></button>
<span id="doc-pdf-zoom-label" style="font-size: 11px; font-weight: 700; color: var(--text-muted); min-width: 42px; text-align: center;">100%</span>
<button class="btn btn-icon" onclick="changeDocPdfZoom(15)" title="Zoom In (+)"><i data-lucide="zoom-in"></i></button>
<button class="btn btn-icon" onclick="rotateDocPdf()" title="Rotate Clockwise (90°)"><i data-lucide="rotate-cw"></i></button>
<button class="btn btn-icon" onclick="printDocViewer()" title="Print Document (Ctrl+P)"><i data-lucide="printer"></i></button>
`;
}
const pdfPanel = document.getElementById('doc-view-pdf');
const frame = document.getElementById('doc-pdf-frame');
if (pdfPanel) pdfPanel.style.display = 'block';
if (filePath.startsWith('client://')) {
resolveClientFileHandle(filePath).then(h => h.getFile()).then(f => {
const clientBlobUrl = URL.createObjectURL(f);
if (frame) frame.src = clientBlobUrl;
if (dlEl) { dlEl.href = clientBlobUrl; dlEl.download = fileName; }
}).catch(err => {
showToast('Failed to load local PDF: ' + err.message, 'error');
});
} else {
if (frame) frame.src = streamUrl;
}
} else if (['md', 'markdown', 'rst'].includes(ext)) {
if (metaEl) metaEl.textContent = 'Markdown Document • Rich GitHub Preview';
if (iconEl) iconEl.setAttribute('data-lucide', 'file-code-2');
if (controlsEl) {
controlsEl.innerHTML = `
<button class="btn btn-icon btn-sm" id="doc-md-toggle-btn" onclick="toggleDocRawRenderMode()" title="View Raw Source Code (Ctrl+U)" style="width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
<i data-lucide="code" style="width: 14px; height: 14px;"></i>
</button>
`;
}
const mdPanel = document.getElementById('doc-view-md');
if (mdPanel) {
mdPanel.style.display = 'block';
mdPanel.innerHTML = '<div style="color: var(--text-muted); padding: 24px; text-align: center;">Loading Markdown...</div>';
}
try {
if (filePath.startsWith('client://')) {
const fileHandle = await resolveClientFileHandle(filePath);
const file = await fileHandle.getFile();
currentDocViewerRawText = await file.text();
} else {
const resp = await fetch(`${endpoint}/api/fs/read?path=${encodeURIComponent(filePath)}`, {
headers
});
if (resp.ok) {
const data = await resp.json();
currentDocViewerRawText = data.content || '';
}
}
if (mdPanel) {
mdPanel.innerHTML = renderMarkdownToHtml(currentDocViewerRawText);
postProcessMarkdownContainer(mdPanel);
}
} catch (e) {
if (mdPanel) mdPanel.innerHTML = `<div style="color: var(--danger); padding: 24px;">Failed to load markdown: ${escapeHtml(e.message)}</div>`;
}
} else if (['csv', 'tsv', 'tab'].includes(ext)) {
const delim = ext === 'tsv' || ext === 'tab' ? '\t' : ',';
if (metaEl) metaEl.textContent = `${ext.toUpperCase()} Data Sheet • Interactive Table`;
if (iconEl) iconEl.setAttribute('data-lucide', 'table');
if (controlsEl) {
controlsEl.innerHTML = `
<div style="display: flex; align-items: center; gap: 6px;">
<input type="text" id="doc-csv-filter" placeholder="Filter rows..." style="background: var(--bg-dark); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 11px; color: var(--text-main); width: 140px;" oninput="filterDocCsv(this.value)">
<span id="doc-csv-stats" style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);">0 rows</span>
</div>
`;
}
const csvPanel = document.getElementById('doc-view-csv');
if (csvPanel) {
csvPanel.style.display = 'block';
csvPanel.innerHTML = '<div style="color: var(--text-muted); padding: 24px; text-align: center;">Parsing CSV table...</div>';
}
try {
if (filePath.startsWith('client://')) {
const fileHandle = await resolveClientFileHandle(filePath);
const file = await fileHandle.getFile();
currentDocViewerRawText = await file.text();
} else {
const resp = await fetch(`${endpoint}/api/fs/read?path=${encodeURIComponent(filePath)}`, {
headers
});
if (resp.ok) {
const data = await resp.json();
currentDocViewerRawText = data.content || '';
}
}
renderCsvData(currentDocViewerRawText, delim);
} catch (e) {
if (csvPanel) csvPanel.innerHTML = `<div style="color: var(--danger); padding: 24px;">Failed to read CSV: ${escapeHtml(e.message)}</div>`;
}
} else if (['html', 'htm', 'xhtml', 'svg', 'xml'].includes(ext)) {
if (metaEl) metaEl.textContent = `${ext.toUpperCase()} Document • Web Preview`;
if (iconEl) iconEl.setAttribute('data-lucide', 'globe');
if (controlsEl) {
controlsEl.innerHTML = `
<button class="btn btn-icon btn-sm" id="doc-md-toggle-btn" onclick="toggleDocRawRenderMode()" title="View Source Code (Ctrl+U)" style="width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
<i data-lucide="code" style="width: 14px; height: 14px;"></i>
</button>
`;
}
const webPanel = document.getElementById('doc-view-web');
const frame = document.getElementById('doc-web-frame');
if (webPanel) webPanel.style.display = 'block';
if (filePath.startsWith('client://')) {
resolveClientFileHandle(filePath).then(h => h.getFile()).then(f => {
const clientBlobUrl = URL.createObjectURL(f);
if (frame) frame.src = clientBlobUrl;
}).catch(err => {
showToast('Failed to load local document: ' + err.message, 'error');
});
} else {
if (frame) frame.src = streamUrl;
}
} else if (['docx', 'xlsx', 'pptx', 'odt', 'ods', 'doc', 'xls', 'ppt'].includes(ext)) {
if (metaEl) metaEl.textContent = `${ext.toUpperCase()} Office Document`;
if (iconEl) iconEl.setAttribute('data-lucide', 'file-text');
if (controlsEl) controlsEl.innerHTML = '';
const officePanel = document.getElementById('doc-view-office');
if (officePanel) {
officePanel.style.display = 'block';
officePanel.innerHTML = `
<div style="max-width: 480px; margin: 40px auto; padding: 28px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);">
<div style="font-size: 42px; margin-bottom: 12px;">📄</div>
<div style="font-size: 16px; font-weight: 700; color: var(--text-main); margin-bottom: 6px;">${escapeHtml(fileName)}</div>
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 20px;">Binary Office document format (${ext.toUpperCase()}).</div>
<div style="display: flex; gap: 10px; justify-content: center;">
<a class="btn btn-accent" href="${downloadUrl}" download style="padding: 8px 16px; font-weight: 600;"><i data-lucide="download"></i> Download File</a>
<a class="btn" href="${streamUrl}" target="_blank" style="padding: 8px 16px;"><i data-lucide="external-link"></i> Open Externally</a>
</div>
</div>
`;
}
} else {
// Plain text / Code / Logs / Conf / Tail -f
if (metaEl) metaEl.textContent = `${ext.toUpperCase() || 'TEXT'} Document`;
if (iconEl) iconEl.setAttribute('data-lucide', 'file-text');
renderTextDocViewerControls();
const textPanel = document.getElementById('doc-view-text');
const contentEl = document.getElementById('doc-text-content');
if (textPanel) textPanel.style.display = 'block';
if (contentEl) contentEl.textContent = 'Loading...';
refreshDocViewerText();
}
showModal('doc-viewer-modal');
if (window.lucide) lucide.createIcons();
}
function closeDocViewerModal() {
if (docViewerTailTimer) {
clearInterval(docViewerTailTimer);
docViewerTailTimer = null;
}
docViewerTailFollow = false;
closeModal('doc-viewer-modal');
const pdfFrame = document.getElementById('doc-pdf-frame');
if (pdfFrame) pdfFrame.src = 'about:blank';
const webFrame = document.getElementById('doc-web-frame');
if (webFrame) webFrame.src = 'about:blank';
}
function toggleDocViewerFullscreen() {
const box = document.getElementById('doc-viewer-box');
if (!box) return;
box.classList.toggle('fullscreen');
const icon = document.getElementById('doc-viewer-fullscreen')?.querySelector('i, svg');
if (icon) {
const isFs = box.classList.contains('fullscreen');
icon.setAttribute('data-lucide', isFs ? 'minimize-2' : 'maximize-2');
if (window.lucide) lucide.createIcons();
}
}
function docViewerEditFile() {
if (currentDocViewerPath) {
closeDocViewerModal();
openEditorWithFile(currentDocViewerPath);
}
}
async function docViewerDeleteCurrent() {
if (!currentDocViewerPath) return;
const filePath = currentDocViewerPath;
const useTrash = App.trashEnabled !== false;
const customTrash = App.customTrashDir || null;
const trashName = getTrashDisplayName(false);
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const confirmed = await showConfirmDialog({
title: useTrash ? `Move to ${trashName} Confirmation` : 'Permanent Deletion Confirmation',
subtitle: useTrash ? `Move document to ${trashName}` : 'Permanently Delete document',
message: useTrash
? `Move '${fileName}' to ${trashName}?`
: `Permanently delete '${fileName}'? This action cannot be undone!`,
items: [fileName],
icon: 'trash-2',
type: 'danger',
confirmText: useTrash ? `Move to ${trashName} (F8)` : 'Permanently Delete (F8)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
const authPath = resolveAuthUri(filePath);
const resp = await fetch('/api/fs/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
paths: [authPath],
use_trash: useTrash,
custom_trash_dir: customTrash,
windows_native_file_ops: App.windowsNativeOps,
detect_locking_processes: App.detectFileLocks
})
});
if (resp.ok) {
showToast(useTrash ? `Moved "${fileName}" to ${trashName}` : `Permanently deleted "${fileName}"`, 'success');
if (App.panes && Array.isArray(App.panes)) {
App.panes.forEach(p => {
if (p && p.selected) p.selected.delete(filePath);
});
}
closeDocViewerModal();
refreshAllPanes();
} else {
showToast(`Delete failed: ${sanitizeCredentials(await resp.text())}`, 'error');
}
} catch (err) {
showToast(`Delete error: ${sanitizeCredentials(String(err))}`, 'error');
}
}
}
async function docViewerMoveCurrent() {
if (!currentDocViewerPath) return;
const filePath = currentDocViewerPath;
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const targetPane = App.panes[targetIdx];
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const destDir = targetPane ? targetPane.path : '/';
const confirmed = await showConfirmDialog({
title: 'Move Document Confirmation',
subtitle: `Move to ${sanitizeCredentials(destDir)}`,
message: `Move '${fileName}' to opposite panel directory?`,
items: [`Source: ${sanitizeCredentials(filePath)}`, `Target: ${sanitizeCredentials(destDir)}`],
icon: 'folder-input',
type: 'warning',
confirmText: 'Move File (F6)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
executeTransfer('move', [filePath], destDir, targetIdx, App.activePaneIndex);
closeDocViewerModal();
} catch (err) {
showToast(`Move error: ${err}`, 'error');
}
}
}
async function docViewerCopyCurrent() {
if (!currentDocViewerPath) return;
const filePath = currentDocViewerPath;
const targetIdx = (App.activePaneIndex + 1) % getVisiblePaneCount();
const targetPane = App.panes[targetIdx];
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const destDir = targetPane ? targetPane.path : '/';
const confirmed = await showConfirmDialog({
title: 'Copy Document Confirmation',
subtitle: `Copy to ${sanitizeCredentials(destDir)}`,
message: `Copy '${fileName}' to opposite panel directory?`,
items: [`Source: ${sanitizeCredentials(filePath)}`, `Target: ${sanitizeCredentials(destDir)}`],
icon: 'copy',
type: 'info',
confirmText: 'Copy File (F5)',
cancelText: 'Cancel',
});
if (confirmed) {
try {
executeTransfer('copy', [filePath], destDir, targetIdx, App.activePaneIndex);
} catch (err) {
showToast(`Copy error: ${err}`, 'error');
}
}
}
async function docViewerRenameCurrent() {
if (!currentDocViewerPath) return;
const filePath = currentDocViewerPath;
const fileName = filePath.split(/[\\/]/).pop() || filePath;
const parts = filePath.split(/[\\/]/);
parts.pop();
const parentDir = parts.join('/') || '/';
const input = document.getElementById('rename-input');
if (!input) return;
input.value = fileName;
showModal('rename-modal');
setTimeout(() => {
input.focus();
const lastDot = fileName.lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
}, 40);
const executeRename = async () => {
const newName = input.value.trim();
if (!newName || newName === fileName) {
closeModal('rename-modal');
return;
}
const toPath = joinItemPath(parentDir, newName);
closeModal('rename-modal');
try {
const resp = await fetch('/api/fs/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ from: resolveAuthUri(filePath), to: resolveAuthUri(toPath) })
});
if (resp.ok) {
showToast(`Renamed to "${newName}"`, 'success');
openDocumentViewer(toPath);
refreshAllPanes();
} else {
showToast('Rename failed: ' + sanitizeCredentials(await resp.text()), 'error');
}
} catch (err) {
showToast('Rename failed: ' + err.message, 'error');
}
};
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeRename();
} else if (e.key === 'Escape') {
e.preventDefault();
closeModal('rename-modal');
}
};
const confirmBtn = document.getElementById('btn-confirm-rename');
if (confirmBtn) confirmBtn.onclick = executeRename;
}
function toggleDocRawRenderMode() {
const fileName = currentDocViewerPath.split('/').pop() || '';
const ext = fileName.split('.').pop().toLowerCase();
const btn = document.getElementById('doc-md-toggle-btn');
const isWeb = ['html', 'htm', 'xhtml', 'svg', 'xml'].includes(ext);
if (currentDocViewerMode === 'rendered') {
currentDocViewerMode = 'raw';
if (btn) {
btn.classList.add('active');
btn.title = isWeb ? 'Switch to Web Preview (Ctrl+P)' : 'Switch to Rendered Preview (Ctrl+P)';
btn.innerHTML = `<i data-lucide="${isWeb ? 'globe' : 'eye'}" style="width: 14px; height: 14px;"></i>`;
}
if (['md', 'markdown', 'rst'].includes(ext)) {
document.getElementById('doc-view-md').style.display = 'none';
const textPanel = document.getElementById('doc-view-text');
const textEl = document.getElementById('doc-text-content');
if (textPanel) textPanel.style.display = 'block';
if (textEl) textEl.textContent = currentDocViewerRawText;
} else if (isWeb) {
document.getElementById('doc-view-web').style.display = 'none';
const textPanel = document.getElementById('doc-view-text');
const textEl = document.getElementById('doc-text-content');
if (textPanel) textPanel.style.display = 'block';
if (textEl) textEl.textContent = currentDocViewerRawText;
}
} else {
currentDocViewerMode = 'rendered';
if (btn) {
btn.classList.remove('active');
btn.title = isWeb ? 'View Source Code (Ctrl+U)' : 'View Raw Source Code (Ctrl+U)';
btn.innerHTML = '<i data-lucide="code" style="width: 14px; height: 14px;"></i>';
}
if (['md', 'markdown', 'rst'].includes(ext)) {
document.getElementById('doc-view-text').style.display = 'none';
const mdPanel = document.getElementById('doc-view-md');
if (mdPanel) {
mdPanel.style.display = 'block';
mdPanel.innerHTML = renderMarkdownToHtml(currentDocViewerRawText);
postProcessMarkdownContainer(mdPanel);
}
} else if (isWeb) {
document.getElementById('doc-view-text').style.display = 'none';
document.getElementById('doc-view-web').style.display = 'block';
}
}
if (window.lucide) lucide.createIcons();
}
function renderRichMarkdown(content) {
return renderMarkdownToHtml(content);
}
function renderCsvData(csvText, delimiter = ',') {
if (!csvText) return;
const parseLine = (line) => {
let result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === delimiter && !inQuotes) {
result.push(current);
current = '';
} else {
current += ch;
}
}
result.push(current);
return result;
};
const rawLines = csvText.split(/\r?\n/).filter(l => l.trim().length > 0);
if (rawLines.length === 0) return;
docCsvHeaders = parseLine(rawLines[0]);
docCsvRows = [];
for (let i = 1; i < rawLines.length; i++) {
docCsvRows.push(parseLine(rawLines[i]));
}
docCsvFiltered = [...docCsvRows];
renderDocCsvTable();
}
function renderDocCsvTable() {
const panel = document.getElementById('doc-view-csv');
const stats = document.getElementById('doc-csv-stats');
if (!panel) return;
if (stats) {
stats.textContent = `${docCsvFiltered.length} / ${docCsvRows.length} rows (${docCsvHeaders.length} cols)`;
}
let html = `
<div class="doc-csv-table-wrapper">
<table class="doc-csv-table">
<thead>
<tr>
<th class="row-num-header">#</th>
${docCsvHeaders.map((h, idx) => `
<th onclick="sortDocCsv(${idx})" title="Click to sort by ${escapeHtml(h)}">
${escapeHtml(h || `Col ${idx + 1}`)} ⇕
</th>
`).join('')}
</tr>
</thead>
<tbody>
${docCsvFiltered.map((row, rIdx) => `
<tr>
<td class="row-num">${rIdx + 1}</td>
${row.map(cell => `<td>${escapeHtml(cell)}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
panel.innerHTML = html;
}
function filterDocCsv(query) {
const q = (query || '').toLowerCase().trim();
if (!q) {
docCsvFiltered = [...docCsvRows];
} else {
docCsvFiltered = docCsvRows.filter(row => {
return row.some(cell => cell.toLowerCase().includes(q));
});
}
renderDocCsvTable();
}
let docCsvSortOrder = 1;
let docCsvLastSortedCol = -1;
function sortDocCsv(colIdx) {
if (docCsvLastSortedCol === colIdx) {
docCsvSortOrder = -docCsvSortOrder;
} else {
docCsvSortOrder = 1;
docCsvLastSortedCol = colIdx;
}
docCsvFiltered.sort((a, b) => {
const valA = (a[colIdx] || '').trim();
const valB = (b[colIdx] || '').trim();
const numA = parseFloat(valA);
const numB = parseFloat(valB);
if (!isNaN(numA) && !isNaN(numB)) {
return (numA - numB) * docCsvSortOrder;
}
return valA.localeCompare(valB) * docCsvSortOrder;
});
renderDocCsvTable();
}
function changeDocPdfZoom(delta) {
docPdfZoom = Math.max(30, Math.min(300, docPdfZoom + delta));
const label = document.getElementById('doc-pdf-zoom-label');
if (label) label.textContent = `${docPdfZoom}%`;
const frame = document.getElementById('doc-pdf-frame');
if (frame) {
frame.style.transform = `scale(${docPdfZoom / 100}) rotate(${docPdfRotation}deg)`;
frame.style.transformOrigin = 'top center';
}
}
function rotateDocPdf() {
docPdfRotation = (docPdfRotation + 90) % 360;
const frame = document.getElementById('doc-pdf-frame');
if (frame) {
frame.style.transform = `scale(${docPdfZoom / 100}) rotate(${docPdfRotation}deg)`;
frame.style.transformOrigin = 'center center';
}
}
function printDocViewer() {
const frame = document.getElementById('doc-pdf-frame') || document.getElementById('doc-web-frame');
if (frame && frame.contentWindow) {
try {
frame.contentWindow.print();
return;
} catch (e) {}
}
window.print();
}
// ==========================================================================
// 🎬 MEDIAPLAYER UNIVERSAL CHEWTOY (FLOATING & IN-PANE DOCKED)
// ==========================================================================
const mediaplayerState = {
playlist: [],
currentIndex: -1,
isPlaying: false,
volume: parseFloat(localStorage.getItem('cd_media_vol') || '1.0'),
isMuted: false,
playbackRate: 1.0,
loopMode: localStorage.getItem('cd_media_loop') || 'none', // 'none' | 'all' | 'one'
isShuffle: localStorage.getItem('cd_media_shuffle') === 'true',
aspectRatio: localStorage.getItem('cd_media_aspect') || 'contain', // 'contain' | 'cover' | 'fill' | '16-9' | '4-3'
isPlaylistOpen: (() => {
const isMobileOrTablet = typeof window !== 'undefined' && window.innerWidth <= 1024;
const saved = localStorage.getItem('cd_media_pl_open');
return saved !== null ? (saved === 'true') : (!isMobileOrTablet);
})(),
isPiP: false,
isFullscreen: false,
subtitles: [],
currentSubtitleIndex: -1,
currentPaneIndex: null,
initialized: false,
controlsTimeout: null,
filterQuery: ''
};
let currentMediaPlayerPaneIndex = null;
let mediaplayerDragInitialized = false;
let mediaplayerKeysInitialized = false;
function getActiveMediaElement() {
const dockedPaneIdx = App.panes ? App.panes.findIndex(p => p && p.dockedTool === 'mediaplayer') : -1;
if (dockedPaneIdx !== -1) {
const dockedVid = document.getElementById(`docked-mediaplayer-video-${dockedPaneIdx}`);
if (dockedVid) return dockedVid;
}
const videoEl = document.getElementById('mediaplayer-video-element');
if (videoEl && videoEl.style.display !== 'none') return videoEl;
return document.getElementById('mediaplayer-audio-element') || videoEl;
}
function openMediaPlayer(filePath = null, mediaType = null, paneIndex = null) {
closeToolsMenu();
currentMediaPlayerPaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
mediaplayerState.currentPaneIndex = currentMediaPlayerPaneIndex;
const dockedPaneIdx = App.panes ? App.panes.findIndex(p => p && p.dockedTool === 'mediaplayer') : -1;
const win = document.getElementById('floating-mediaplayer-window');
const pill = document.getElementById('mediaplayer-pill');
if (pill) pill.style.display = 'none';
if (dockedPaneIdx === -1) {
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
}
initMediaplayerDragAndResize();
initMediaplayerKeyboardShortcuts();
initMediaControlsAutohide();
// Apply saved aspect ratio & volume
setMediaAspectRatio(mediaplayerState.aspectRatio);
changeMediaVolume(mediaplayerState.volume);
if (filePath) {
// Populate playlist from active pane folder if not already containing files
const pane = App.panes[currentMediaPlayerPaneIndex];
if (pane && pane.entries) {
const mediaEntries = pane.entries.filter(e => !e.is_dir && (isAudioExtension(e.name) || isVideoExtension(e.name)));
mediaplayerState.playlist = mediaEntries.map(e => ({
path: e.path,
name: e.name,
isVideo: isVideoExtension(e.name),
paneIndex: currentMediaPlayerPaneIndex
}));
}
if (mediaplayerState.playlist.length === 0) {
mediaplayerState.playlist = [{
path: filePath,
name: filePath.split('/').pop() || filePath,
isVideo: isVideoExtension(filePath),
paneIndex: currentMediaPlayerPaneIndex
}];
}
const idx = mediaplayerState.playlist.findIndex(t => t.path === filePath);
mediaplayerState.currentIndex = idx !== -1 ? idx : 0;
loadMediaTrack(filePath, mediaType, currentMediaPlayerPaneIndex);
} else if (mediaplayerState.playlist.length === 0) {
populateMediaPlaylistFromActivePane();
}
// Ensure playlist collapsed state is applied on phone / tablet viewports (<= 1024px)
if (win) {
win.classList.toggle('playlist-collapsed', !mediaplayerState.isPlaylistOpen);
}
const plBtn = document.getElementById('btn-mediaplayer-playlist-toggle');
if (plBtn) plBtn.classList.toggle('active', !!mediaplayerState.isPlaylistOpen);
renderMediaPlaylist();
updateMediaLoopButton();
updateMediaShuffleButton();
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
function loadMediaTrack(filePath, forcedType = null, paneIndex = null) {
if (!filePath) return;
const pIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : currentMediaPlayerPaneIndex;
const fileName = filePath.split('/').pop() || filePath;
const ext = fileName.split('.').pop().toLowerCase();
const isVideo = forcedType === 'video' || isVideoExtension(fileName);
const titleEl = document.getElementById('mediaplayer-header-title');
const badgeEl = document.getElementById('mediaplayer-badge');
const videoEl = document.getElementById('mediaplayer-video-element');
const audioEl = document.getElementById('mediaplayer-audio-element');
const vizEl = document.getElementById('mediaplayer-audio-visualizer');
const audioTitleEl = document.getElementById('mediaplayer-audio-title');
const audioPathEl = document.getElementById('mediaplayer-audio-path');
const pillTitle = document.getElementById('mediaplayer-pill-title');
if (titleEl) titleEl.textContent = fileName;
if (badgeEl) badgeEl.textContent = ext.toUpperCase();
if (pillTitle) pillTitle.textContent = fileName;
if (audioTitleEl) audioTitleEl.textContent = fileName.replace(/\.[^/.]+$/, '');
if (audioPathEl) audioPathEl.textContent = sanitizeCredentials(filePath);
const streamUrl = getDownloadUrl(filePath, true, pIdx);
const errOverlay = document.getElementById('mediaplayer-error-overlay');
if (errOverlay) errOverlay.style.display = 'none';
// Clear existing subtitles
clearMediaSubtitles();
if (isVideo) {
if (vizEl) vizEl.style.display = 'none';
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
if (videoEl) {
videoEl.style.display = 'block';
videoEl.src = streamUrl;
videoEl.currentTime = 0;
videoEl.playbackRate = mediaplayerState.playbackRate;
videoEl.volume = mediaplayerState.isMuted ? 0 : mediaplayerState.volume;
videoEl.play().catch(() => {});
}
// Scan matching subtitles in directory
scanAndAttachSubtitles(filePath, pIdx);
} else {
if (videoEl) {
videoEl.pause();
videoEl.style.display = 'none';
videoEl.src = '';
}
if (vizEl) vizEl.style.display = 'flex';
if (audioEl) {
audioEl.src = streamUrl;
audioEl.currentTime = 0;
audioEl.playbackRate = mediaplayerState.playbackRate;
audioEl.volume = mediaplayerState.isMuted ? 0 : mediaplayerState.volume;
audioEl.play().catch(() => {});
}
}
// Also update docked video element and track label if docked
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'mediaplayer') {
const dockedVid = document.getElementById(`docked-mediaplayer-video-${idx}`);
const dockedTitle = document.getElementById(`docked-mediaplayer-track-title-${idx}`);
if (dockedTitle) dockedTitle.textContent = fileName;
if (dockedVid) {
dockedVid.src = streamUrl;
dockedVid.currentTime = 0;
dockedVid.playbackRate = mediaplayerState.playbackRate;
dockedVid.volume = mediaplayerState.isMuted ? 0 : mediaplayerState.volume;
dockedVid.play().catch(() => {});
}
}
});
attachMediaEvents();
renderMediaPlaylist();
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
function attachMediaEvents() {
const el = getActiveMediaElement();
if (!el) return;
el.ontimeupdate = () => {
const cur = el.currentTime || 0;
const dur = el.duration || 0;
const pct = dur > 0 ? (cur / dur) * 100 : 0;
const progEl = document.getElementById('mediaplayer-scrubber-progress');
if (progEl) progEl.style.width = `${pct}%`;
// Buffer percentage
const bufEl = document.getElementById('mediaplayer-scrubber-buffer');
if (bufEl && el.buffered && el.buffered.length > 0 && dur > 0) {
try {
const bufferedEnd = el.buffered.end(el.buffered.length - 1);
const bufPct = Math.min(100, (bufferedEnd / dur) * 100);
bufEl.style.width = `${bufPct}%`;
} catch (e) {}
}
const curEl = document.getElementById('mediaplayer-time-current');
const totEl = document.getElementById('mediaplayer-time-total');
if (curEl) curEl.textContent = formatMediaTime(cur);
if (totEl) totEl.textContent = formatMediaTime(dur);
// Sync docked mediaplayer scrubber and time displays
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'mediaplayer') {
const dProg = document.getElementById(`docked-mediaplayer-progress-${idx}`);
const dCur = document.getElementById(`docked-mediaplayer-time-cur-${idx}`);
const dTot = document.getElementById(`docked-mediaplayer-time-tot-${idx}`);
if (dProg) dProg.style.width = `${pct}%`;
if (dCur) dCur.textContent = formatMediaTime(cur);
if (dTot) dTot.textContent = formatMediaTime(dur);
}
});
// Sync mediaplayer minimized pill time and progress
const pillTime = document.getElementById('mediaplayer-pill-time');
if (pillTime) pillTime.textContent = `${formatMediaTime(cur)} / ${dur > 0 ? formatMediaTime(dur) : '00:00'}`;
const pillProg = document.getElementById('mediaplayer-pill-progress-fill');
if (pillProg) pillProg.style.width = `${pct}%`;
};
el.onplay = () => updateMediaPlayButton(true);
el.onpause = () => updateMediaPlayButton(false);
el.onended = () => {
if (mediaplayerState.loopMode === 'one') {
el.currentTime = 0;
el.play();
} else if (mediaplayerState.loopMode === 'all' || mediaplayerState.playlist.length > 1) {
navMediaPlaylist(1);
} else {
updateMediaPlayButton(false);
}
};
el.onloadedmetadata = () => {
if (el.tagName === 'VIDEO' && el.videoWidth === 0 && el.videoHeight === 0 && el.duration > 0) {
const errOverlay = document.getElementById('mediaplayer-error-overlay');
const errTitle = document.getElementById('mediaplayer-error-title');
const errDesc = document.getElementById('mediaplayer-error-desc');
const errFilename = document.getElementById('mediaplayer-error-filename');
const curTrack = mediaplayerState.playlist[mediaplayerState.currentIndex] || { path: '', name: '' };
if (errFilename) errFilename.textContent = curTrack.name || curTrack.path || 'Media File';
if (errTitle) errTitle.textContent = 'Audio Plays, but Video Codec is Unsupported';
if (errDesc) errDesc.textContent = 'The AAC audio track is playing, but your browser cannot decode the legacy video stream (MPEG-4 Part 2 / mp4v). Click Convert to Web MP4 to transcode with ConvertX, or open in VLC/MPV.';
if (errOverlay) {
errOverlay.style.display = 'flex';
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
}
};
el.onerror = () => {
const errOverlay = document.getElementById('mediaplayer-error-overlay');
const errTitle = document.getElementById('mediaplayer-error-title');
const errDesc = document.getElementById('mediaplayer-error-desc');
const errFilename = document.getElementById('mediaplayer-error-filename');
const curTrack = mediaplayerState.playlist[mediaplayerState.currentIndex] || { path: '', name: '' };
const curExt = (curTrack.name || curTrack.path || '').split('.').pop().toLowerCase();
if (errFilename) errFilename.textContent = curTrack.name || curTrack.path || 'Media File';
if (['avi', 'mpg', 'mpeg', 'wmv', 'flv', 'vob', '3gp', 'divx', 'asf'].includes(curExt)) {
if (errTitle) errTitle.textContent = `Unsupported Video Format (.${curExt.toUpperCase()})`;
if (errDesc) errDesc.textContent = `HTML5 browser engines cannot decode .${curExt.toUpperCase()} (DivX / MPEG / WMV) natively. You can convert it to Web MP4 with 1-click ConvertX, or launch your system media player (VLC/MPV).`;
} else {
if (errTitle) errTitle.textContent = 'Media Playback Error';
if (errDesc) errDesc.textContent = `The media stream or codec could not be decoded by the browser engine.`;
}
if (errOverlay) {
errOverlay.style.display = 'flex';
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
updateMediaPlayButton(false);
};
}
function convertCurrentMediaToMp4() {
const curTrack = mediaplayerState.playlist[mediaplayerState.currentIndex];
if (!curTrack || !curTrack.path) {
showToast('No media track selected', 'warning');
return;
}
const pIdx = (curTrack.paneIndex !== null && curTrack.paneIndex !== undefined) ? curTrack.paneIndex : currentMediaPlayerPaneIndex;
closeMediaPlayer();
openConverterModal(curTrack.path, 'mp4', pIdx);
}
function openCurrentMediaWithSystemPlayer() {
const curTrack = mediaplayerState.playlist[mediaplayerState.currentIndex];
if (!curTrack || !curTrack.path) {
showToast('No media track selected', 'warning');
return;
}
closeMediaPlayer();
executeOpenWith(curTrack.path, null);
}
function downloadCurrentMedia() {
const curTrack = mediaplayerState.playlist[mediaplayerState.currentIndex];
if (!curTrack || !curTrack.path) {
showToast('No media track selected', 'warning');
return;
}
const pIdx = (curTrack.paneIndex !== null && curTrack.paneIndex !== undefined) ? curTrack.paneIndex : currentMediaPlayerPaneIndex;
const url = getDownloadUrl(curTrack.path, false, pIdx);
const a = document.createElement('a');
a.href = url;
a.download = curTrack.name || curTrack.path.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function formatMediaTime(secs) {
if (isNaN(secs) || secs === 0) return '00:00';
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
const h = Math.floor(m / 60);
if (h > 0) {
const remM = m % 60;
return `${h}:${remM < 10 ? '0' : ''}${remM}:${s < 10 ? '0' : ''}${s}`;
}
return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`;
}
function updateMediaPlayButton(playing) {
mediaplayerState.isPlaying = playing;
const playBtn = document.getElementById('btn-mediaplayer-play');
const pillPlay = document.getElementById('mediaplayer-pill-play-icon');
const pill = document.getElementById('mediaplayer-pill');
if (playBtn) {
playBtn.innerHTML = `<i data-lucide="${playing ? 'pause' : 'play'}" style="width: 14px;"></i>`;
}
if (pillPlay) {
pillPlay.setAttribute('data-lucide', playing ? 'pause' : 'play');
}
if (pill) {
pill.classList.toggle('playing', !!playing);
}
// Update docked play buttons
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'mediaplayer') {
const dBtn = document.getElementById(`docked-mediaplayer-play-btn-${idx}`);
if (dBtn) dBtn.innerHTML = `<i data-lucide="${playing ? 'pause' : 'play'}" style="width: 12px; height: 12px;"></i>`;
}
});
// Background playback task pill handling
const win = document.getElementById('floating-mediaplayer-window');
if (pill) {
const isFloatingOpen = win && win.style.display !== 'none';
const isDocked = App.panes && App.panes.some(p => p && p.dockedTool === 'mediaplayer');
if (playing && !isFloatingOpen && !isDocked) {
pill.style.display = 'flex';
updateMediaPill();
}
}
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
if (typeof updateAppDocumentTitle === 'function') {
updateAppDocumentTitle();
}
}
function toggleMediaPlay() {
const el = getActiveMediaElement();
if (!el) return;
if (el.paused) {
el.play().then(() => {
showMediaOSD('play', 'Playing');
}).catch(() => {});
} else {
el.pause();
showMediaOSD('pause', 'Paused');
}
}
function seekMedia(e) {
const el = getActiveMediaElement();
if (!el || !el.duration) return;
const track = e.currentTarget || document.getElementById('mediaplayer-scrubber-track');
if (!track) return;
const rect = track.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = Math.max(0, Math.min(1, clickX / rect.width));
el.currentTime = pct * el.duration;
}
function skipMedia(seconds) {
const el = getActiveMediaElement();
if (!el || !el.duration) return;
el.currentTime = Math.max(0, Math.min(el.duration, el.currentTime + seconds));
showMediaOSD(seconds > 0 ? 'rotate-cw' : 'rotate-ccw', `${seconds > 0 ? '+' : ''}${seconds}s`);
}
function changeMediaVolume(val) {
const v = Math.max(0, Math.min(1, parseFloat(val)));
mediaplayerState.volume = v;
localStorage.setItem('cd_media_vol', v.toString());
const videoEl = document.getElementById('mediaplayer-video-element');
const audioEl = document.getElementById('mediaplayer-audio-element');
const slider = document.getElementById('mediaplayer-volume-slider');
const volIcon = document.getElementById('mediaplayer-volume-icon');
if (videoEl) videoEl.volume = mediaplayerState.isMuted ? 0 : v;
if (audioEl) audioEl.volume = mediaplayerState.isMuted ? 0 : v;
if (slider) slider.value = v;
if (volIcon) {
const iconName = (mediaplayerState.isMuted || v === 0) ? 'volume-x' : (v < 0.5 ? 'volume-1' : 'volume-2');
volIcon.setAttribute('data-lucide', iconName);
if (window.lucide) lucide.createIcons();
}
}
function toggleMediaMute() {
mediaplayerState.isMuted = !mediaplayerState.isMuted;
changeMediaVolume(mediaplayerState.volume);
showMediaOSD(mediaplayerState.isMuted ? 'volume-x' : 'volume-2', mediaplayerState.isMuted ? 'Muted' : `${Math.round(mediaplayerState.volume * 100)}%`);
}
const MEDIA_SPEEDS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
function cycleMediaSpeed() {
const curSpeed = mediaplayerState.playbackRate;
const idx = MEDIA_SPEEDS.indexOf(curSpeed);
const nextSpeed = idx !== -1 ? MEDIA_SPEEDS[(idx + 1) % MEDIA_SPEEDS.length] : 1.0;
changeMediaSpeed(nextSpeed);
}
function changeMediaSpeed(val) {
const speed = parseFloat(val) || 1.0;
mediaplayerState.playbackRate = speed;
const el = getActiveMediaElement();
if (el) el.playbackRate = speed;
const badge = document.getElementById('mediaplayer-speed-badge');
if (badge) badge.textContent = `${speed}x`;
showMediaOSD('gauge', `${speed}x Speed`);
}
const MEDIA_ASPECT_RATIOS = ['contain', 'cover', 'fill', '16-9', '4-3'];
function cycleMediaAspectRatio() {
const curIdx = MEDIA_ASPECT_RATIOS.indexOf(mediaplayerState.aspectRatio);
const nextMode = MEDIA_ASPECT_RATIOS[(curIdx + 1) % MEDIA_ASPECT_RATIOS.length];
setMediaAspectRatio(nextMode);
}
function setMediaAspectRatio(mode) {
mediaplayerState.aspectRatio = mode;
localStorage.setItem('cd_media_aspect', mode);
const videoEl = document.getElementById('mediaplayer-video-element');
if (videoEl) {
videoEl.classList.remove('aspect-contain', 'aspect-cover', 'aspect-fill', 'aspect-16-9', 'aspect-4-3');
videoEl.classList.add(`aspect-${mode}`);
}
const labelMap = { 'contain': 'FIT (Contain)', 'cover': 'COVER (Crop)', 'fill': 'STRETCH (Fill)', '16-9': '16:9 Wide', '4-3': '4:3 Standard' };
showMediaOSD('ratio', labelMap[mode] || mode.toUpperCase());
}
async function toggleMediaPiP() {
const videoEl = document.getElementById('mediaplayer-video-element');
if (!videoEl) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
mediaplayerState.isPiP = false;
showMediaOSD('picture-in-picture-2', 'PiP Closed');
} else if (document.pictureInPictureEnabled) {
await videoEl.requestPictureInPicture();
mediaplayerState.isPiP = true;
showMediaOSD('picture-in-picture-2', 'PiP Detached');
} else {
showToast('Picture-in-Picture not supported in this browser', 'info');
}
} catch (err) {
console.debug('PiP toggle notice:', err);
}
}
function toggleMediaFullscreen() {
const win = document.getElementById('floating-mediaplayer-window');
if (!win) return;
if (!document.fullscreenElement) {
if (win.requestFullscreen) {
win.requestFullscreen().catch(() => {});
} else if (win.webkitRequestFullscreen) {
win.webkitRequestFullscreen();
}
win.classList.add('fullscreen-mode');
mediaplayerState.isFullscreen = true;
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
win.classList.remove('fullscreen-mode');
mediaplayerState.isFullscreen = false;
}
}
document.addEventListener('fullscreenchange', () => {
const win = document.getElementById('floating-mediaplayer-window');
if (!win) return;
const isFs = !!document.fullscreenElement;
win.classList.toggle('fullscreen-mode', isFs);
mediaplayerState.isFullscreen = isFs;
const fsBtn = document.getElementById('btn-mediaplayer-fullscreen');
if (fsBtn) {
fsBtn.innerHTML = `<i data-lucide="${isFs ? 'minimize' : 'maximize'}"></i>`;
if (window.lucide) lucide.createIcons();
}
});
function cycleMediaLoop() {
const modes = ['none', 'all', 'one'];
const curIdx = modes.indexOf(mediaplayerState.loopMode);
mediaplayerState.loopMode = modes[(curIdx + 1) % modes.length];
localStorage.setItem('cd_media_loop', mediaplayerState.loopMode);
updateMediaLoopButton();
const labelMap = { 'none': 'Loop Off', 'all': 'Loop Playlist', 'one': 'Loop Track' };
showMediaOSD('repeat', labelMap[mediaplayerState.loopMode]);
}
function updateMediaLoopButton() {
const btn = document.getElementById('btn-mediaplayer-loop');
if (!btn) return;
if (mediaplayerState.loopMode === 'none') {
btn.style.color = 'var(--text-main)';
btn.innerHTML = '<i data-lucide="repeat"></i>';
} else if (mediaplayerState.loopMode === 'all') {
btn.style.color = 'var(--accent)';
btn.innerHTML = '<i data-lucide="repeat"></i>';
} else if (mediaplayerState.loopMode === 'one') {
btn.style.color = 'var(--accent)';
btn.innerHTML = '<i data-lucide="repeat-1"></i>';
}
if (window.lucide) lucide.createIcons();
}
function toggleMediaShuffle() {
mediaplayerState.isShuffle = !mediaplayerState.isShuffle;
localStorage.setItem('cd_media_shuffle', mediaplayerState.isShuffle ? 'true' : 'false');
updateMediaShuffleButton();
showMediaOSD('shuffle', mediaplayerState.isShuffle ? 'Shuffle On' : 'Shuffle Off');
}
function updateMediaShuffleButton() {
const btn = document.getElementById('btn-mediaplayer-shuffle');
if (btn) {
btn.style.color = mediaplayerState.isShuffle ? 'var(--accent)' : 'var(--text-main)';
}
}
function toggleMediaPlaylistDrawer() {
const win = document.getElementById('floating-mediaplayer-window');
if (!win) return;
mediaplayerState.isPlaylistOpen = !mediaplayerState.isPlaylistOpen;
localStorage.setItem('cd_media_pl_open', mediaplayerState.isPlaylistOpen ? 'true' : 'false');
win.classList.toggle('playlist-collapsed', !mediaplayerState.isPlaylistOpen);
const plBtn = document.getElementById('btn-mediaplayer-playlist-toggle');
if (plBtn) plBtn.classList.toggle('active', !!mediaplayerState.isPlaylistOpen);
}
function populateMediaPlaylistFromActivePane() {
const pane = App.panes[App.activePaneIndex];
if (!pane || !pane.entries) return;
const mediaEntries = pane.entries.filter(e => !e.is_dir && (isAudioExtension(e.name) || isVideoExtension(e.name)));
if (mediaEntries.length === 0) {
showToast('No media files found in current panel', 'info');
return;
}
mediaplayerState.playlist = mediaEntries.map(e => ({
path: e.path,
name: e.name,
isVideo: isVideoExtension(e.name),
paneIndex: App.activePaneIndex
}));
mediaplayerState.currentIndex = 0;
renderMediaPlaylist();
loadMediaTrack(mediaplayerState.playlist[0].path, null, App.activePaneIndex);
showToast(`Loaded ${mediaEntries.length} media files to playlist`, 'success');
}
function clearMediaPlaylist() {
mediaplayerState.playlist = [];
mediaplayerState.currentIndex = -1;
const videoEl = document.getElementById('mediaplayer-video-element');
const audioEl = document.getElementById('mediaplayer-audio-element');
if (videoEl) { videoEl.pause(); videoEl.src = ''; }
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
renderMediaPlaylist();
showToast('Playlist cleared', 'info');
}
function filterMediaPlaylist(query) {
mediaplayerState.filterQuery = query.toLowerCase().trim();
renderMediaPlaylist();
}
function renderMediaPlaylist() {
const listEl = document.getElementById('mediaplayer-playlist-list');
const countBadge = document.getElementById('mediaplayer-playlist-count');
if (!listEl) return;
if (countBadge) countBadge.textContent = mediaplayerState.playlist.length;
const filtered = mediaplayerState.playlist.map((t, idx) => ({ ...t, originalIndex: idx }))
.filter(t => !mediaplayerState.filterQuery || t.name.toLowerCase().includes(mediaplayerState.filterQuery));
if (filtered.length === 0) {
listEl.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--text-dim); font-size: 11px;">No playlist items</div>`;
return;
}
listEl.innerHTML = filtered.map(t => {
const isActive = t.originalIndex === mediaplayerState.currentIndex;
const ext = t.name.split('.').pop();
return `
<div class="mediaplayer-playlist-item ${isActive ? 'active' : ''}" onclick="playMediaPlaylistItem(${t.originalIndex})">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden; flex: 1;">
<i data-lucide="${t.isVideo ? 'video' : 'music'}" style="width: 12px; height: 12px; color: ${isActive ? 'var(--accent)' : 'var(--text-dim)'}; flex-shrink: 0;"></i>
<span class="mediaplayer-playlist-item-title">${escapeHtml(t.name)}</span>
</div>
<span class="mediaplayer-playlist-item-ext">${ext}</span>
</div>
`;
}).join('');
if (window.lucide) {
try { lucide.createIcons({ root: listEl }); } catch (e) {}
}
}
function playMediaPlaylistItem(idx) {
if (idx < 0 || idx >= mediaplayerState.playlist.length) return;
mediaplayerState.currentIndex = idx;
const track = mediaplayerState.playlist[idx];
loadMediaTrack(track.path, null, track.paneIndex);
}
function navMediaPlaylist(dir) {
if (mediaplayerState.playlist.length === 0) return;
let nextIdx = 0;
if (mediaplayerState.isShuffle && mediaplayerState.playlist.length > 1) {
nextIdx = Math.floor(Math.random() * mediaplayerState.playlist.length);
if (nextIdx === mediaplayerState.currentIndex) {
nextIdx = (nextIdx + 1) % mediaplayerState.playlist.length;
}
} else {
nextIdx = (mediaplayerState.currentIndex + dir + mediaplayerState.playlist.length) % mediaplayerState.playlist.length;
}
playMediaPlaylistItem(nextIdx);
}
// ---------------- SUBTITLES & WEBVTT PARSER ----------------
async function scanAndAttachSubtitles(videoPath, paneIndex) {
mediaplayerState.subtitles = [];
mediaplayerState.currentSubtitleIndex = -1;
try {
const dirPath = videoPath.substring(0, videoPath.lastIndexOf('/')) || '/';
const videoBase = videoPath.split('/').pop().replace(/\.[^/.]+$/, '').toLowerCase();
const endpoint = getPaneEndpoint(paneIndex);
const authHeaders = getPaneAuthHeaders(paneIndex);
const resp = await fetch(`${endpoint}/api/fs/list?path=${encodeURIComponent(dirPath)}`, {
headers: { ...authHeaders }
});
if (!resp.ok) return;
const data = await resp.json();
const subFiles = (data.entries || []).filter(e => {
if (e.is_dir) return false;
const lower = e.name.toLowerCase();
const ext = lower.split('.').pop();
return ['srt', 'vtt', 'sub', 'ass'].includes(ext) && (lower.startsWith(videoBase) || subFilesOnlyMatch(videoBase, lower));
});
for (const file of subFiles) {
try {
const readResp = await fetch(`${endpoint}/api/fs/read?path=${encodeURIComponent(file.path)}`, {
headers: { ...authHeaders }
});
if (readResp.ok) {
const fileData = await readResp.json();
let vttContent = fileData.content || '';
if (file.name.endsWith('.srt')) {
vttContent = parseSrtToVtt(vttContent);
}
const blobUrl = URL.createObjectURL(new Blob([vttContent], { type: 'text/vtt' }));
const label = extractSubtitleLabel(file.name, videoBase);
mediaplayerState.subtitles.push({
name: file.name,
label: label,
blobUrl: blobUrl
});
}
} catch (e) {}
}
renderSubtitlesDropdown();
// Auto-enable first subtitle track if available
if (mediaplayerState.subtitles.length > 0) {
setMediaSubtitleTrack(0);
}
} catch (err) {
console.debug('Subtitle scan notice:', err);
}
}
function subFilesOnlyMatch(base, subName) {
const cleanSub = subName.replace(/\.(srt|vtt|sub|ass)$/i, '').toLowerCase();
return cleanSub.includes(base) || base.includes(cleanSub);
}
function extractSubtitleLabel(subName, base) {
const stripped = subName.replace(/\.[^/.]+$/, '');
const diff = stripped.substring(base.length).replace(/^[._-]+/, '');
if (!diff) return 'Default (Track 1)';
const langMap = { 'en': 'English', 'eng': 'English', 'no': 'Norwegian', 'nor': 'Norwegian', 'es': 'Spanish', 'fr': 'French', 'de': 'German', 'it': 'Italian', 'ja': 'Japanese', 'ko': 'Korean', 'zh': 'Chinese' };
return langMap[diff.toLowerCase()] || diff.toUpperCase();
}
function parseSrtToVtt(srtText) {
let vtt = "WEBVTT\n\n";
// Replace SRT comma millisecond timestamps with period for WebVTT
const converted = srtText.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2');
return vtt + converted;
}
function clearMediaSubtitles() {
const videoEl = document.getElementById('mediaplayer-video-element');
if (videoEl) {
const tracks = videoEl.querySelectorAll('track');
tracks.forEach(t => t.remove());
}
mediaplayerState.subtitles = [];
mediaplayerState.currentSubtitleIndex = -1;
renderSubtitlesDropdown();
}
function toggleMediaSubtitlesDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('mediaplayer-subtitles-dropdown');
if (!dropdown) return;
const isVisible = dropdown.style.display === 'block';
dropdown.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
const closeListener = (e) => {
if (!dropdown.contains(e.target)) {
dropdown.style.display = 'none';
document.removeEventListener('click', closeListener);
}
};
setTimeout(() => document.addEventListener('click', closeListener), 10);
}
}
function renderSubtitlesDropdown() {
const list = document.getElementById('mediaplayer-subtitles-list');
const subsBtn = document.getElementById('btn-mediaplayer-subs');
if (!list) return;
if (subsBtn) {
subsBtn.style.color = mediaplayerState.currentSubtitleIndex !== -1 ? 'var(--accent)' : 'var(--text-main)';
}
let html = `
<div class="mediaplayer-sub-item ${mediaplayerState.currentSubtitleIndex === -1 ? 'active' : ''}" onclick="setMediaSubtitleTrack(-1)">
<span>Off</span>
${mediaplayerState.currentSubtitleIndex === -1 ? '<i data-lucide="check" style="width: 12px;"></i>' : ''}
</div>
`;
mediaplayerState.subtitles.forEach((sub, idx) => {
const isActive = mediaplayerState.currentSubtitleIndex === idx;
html += `
<div class="mediaplayer-sub-item ${isActive ? 'active' : ''}" onclick="setMediaSubtitleTrack(${idx})">
<span>${escapeHtml(sub.label)}</span>
${isActive ? '<i data-lucide="check" style="width: 12px;"></i>' : ''}
</div>
`;
});
list.innerHTML = html;
if (window.lucide) {
try { lucide.createIcons({ root: list }); } catch (e) {}
}
}
function setMediaSubtitleTrack(index) {
mediaplayerState.currentSubtitleIndex = index;
const videoEl = document.getElementById('mediaplayer-video-element');
if (!videoEl) return;
// Clear existing tracks
const tracks = videoEl.querySelectorAll('track');
tracks.forEach(t => t.remove());
if (index >= 0 && index < mediaplayerState.subtitles.length) {
const sub = mediaplayerState.subtitles[index];
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = sub.label;
track.src = sub.blobUrl;
track.default = true;
videoEl.appendChild(track);
if (videoEl.textTracks && videoEl.textTracks[0]) {
videoEl.textTracks[0].mode = 'showing';
}
showMediaOSD('captions', `Subtitles: ${sub.label}`);
} else {
showMediaOSD('captions', 'Subtitles Off');
}
renderSubtitlesDropdown();
const dropdown = document.getElementById('mediaplayer-subtitles-dropdown');
if (dropdown) dropdown.style.display = 'none';
}
function cycleMediaSubtitles() {
if (mediaplayerState.subtitles.length === 0) {
showMediaOSD('captions', 'No Subtitles Available');
return;
}
const nextIdx = (mediaplayerState.currentSubtitleIndex + 2) % (mediaplayerState.subtitles.length + 1) - 1;
setMediaSubtitleTrack(nextIdx);
}
// ---------------- WINDOWING & RESIZERS ----------------
function initMediaplayerDragAndResize() {
if (mediaplayerDragInitialized) return;
mediaplayerDragInitialized = true;
const win = document.getElementById('floating-mediaplayer-window');
const header = document.getElementById('mediaplayer-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_mediaplayer_x');
const savedTop = localStorage.getItem('cd_mediaplayer_y');
const savedWidth = localStorage.getItem('cd_mediaplayer_w');
const savedHeight = localStorage.getItem('cd_mediaplayer_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(460, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(320, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input') || e.target.closest('.dropdown-menu')) return;
if (win.classList.contains('maximized') || win.classList.contains('fullscreen-mode')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
win.style.right = 'auto';
win.style.bottom = 'auto';
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_mediaplayer_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_mediaplayer_y', parseInt(win.style.top, 10));
}
});
setupMediaplayerResizers(win);
}
function setupMediaplayerResizers(win) {
const handles = {
top: document.getElementById('mediaplayer-resize-top'),
bottom: document.getElementById('mediaplayer-resize-bottom'),
left: document.getElementById('mediaplayer-resize-left'),
right: document.getElementById('mediaplayer-resize-right'),
corner: document.getElementById('mediaplayer-resize-corner')
};
let resizeMode = null;
let startX = 0, startY = 0;
let startW = 0, startH = 0;
let startLeft = 0, startTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
if (win.classList.contains('maximized') || win.classList.contains('fullscreen-mode')) return;
resizeMode = mode;
bringFloatingWindowToFront(win);
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
if (mode === 'top' || mode === 'bottom') document.body.style.cursor = 'ns-resize';
else if (mode === 'left' || mode === 'right') document.body.style.cursor = 'ew-resize';
else document.body.style.cursor = 'nwse-resize';
});
});
window.addEventListener('mousemove', (e) => {
if (!resizeMode) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (resizeMode === 'bottom' || resizeMode === 'corner') {
const newH = Math.max(320, Math.min(window.innerHeight - startTop - 20, startH + dy));
win.style.height = `${newH}px`;
}
if (resizeMode === 'right' || resizeMode === 'corner') {
const newW = Math.max(460, Math.min(window.innerWidth - startLeft - 20, startW + dx));
win.style.width = `${newW}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(460, startW - dx);
const newLeft = startLeft + (startW - newW);
if (newLeft >= 0) {
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
}
if (resizeMode === 'top') {
const newH = Math.max(320, startH - dy);
const newTop = startTop + (startH - newH);
if (newTop >= 35) {
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
});
window.addEventListener('mouseup', () => {
if (resizeMode) {
resizeMode = null;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_mediaplayer_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_mediaplayer_h', parseInt(win.style.height, 10));
}
});
}
function dismissMediaPill() {
const videoEl = document.getElementById('mediaplayer-video-element');
const audioEl = document.getElementById('mediaplayer-audio-element');
if (videoEl) { videoEl.pause(); videoEl.src = ''; }
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
mediaplayerState.isPlaying = false;
updateMediaPlayButton(false);
const pill = document.getElementById('mediaplayer-pill');
if (pill) pill.style.display = 'none';
}
function updateMediaPill() {
const pillTitle = document.getElementById('mediaplayer-pill-title');
const pillPlay = document.getElementById('mediaplayer-pill-play-icon');
const pill = document.getElementById('mediaplayer-pill');
if (mediaplayerState.currentIndex >= 0 && mediaplayerState.playlist[mediaplayerState.currentIndex]) {
const cur = mediaplayerState.playlist[mediaplayerState.currentIndex];
if (pillTitle) pillTitle.textContent = cur.name || (cur.path ? cur.path.split('/').pop() : 'Media Track');
} else {
if (pillTitle) pillTitle.textContent = 'Mediaplayer';
}
if (pillPlay) {
pillPlay.setAttribute('data-lucide', mediaplayerState.isPlaying ? 'pause' : 'play');
}
if (pill) {
pill.classList.toggle('playing', !!mediaplayerState.isPlaying);
}
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
function closeMediaPlayer(force = false) {
const stopOnClose = localStorage.getItem('cd_media_stop_on_close') !== 'false';
if (!force && !stopOnClose && mediaplayerState.isPlaying) {
minimizeFloatingMediaPlayer();
showToast('Media Player minimized to background pill', 'info');
return;
}
dismissMediaPill();
const win = document.getElementById('floating-mediaplayer-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingMediaPlayer() {
const win = document.getElementById('floating-mediaplayer-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('mediaplayer-pill');
if (pill) {
pill.style.display = 'flex';
updateMediaPill();
}
}
function restoreFloatingMediaPlayer() {
const pill = document.getElementById('mediaplayer-pill');
if (pill) pill.style.display = 'none';
const win = document.getElementById('floating-mediaplayer-window');
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
}
function maximizeFloatingMediaPlayer() {
const win = document.getElementById('floating-mediaplayer-window');
if (win) win.classList.toggle('maximized');
}
function dockMediaPlayerToActivePane() {
dockToolToPane('mediaplayer', App.activePaneIndex);
}
function mountDockedMediaPlayer(paneIndex) {
const host = document.getElementById(`docked-mediaplayer-host-${paneIndex}`);
const dockedVid = document.getElementById(`docked-mediaplayer-video-${paneIndex}`);
const dockedTitle = document.getElementById(`docked-mediaplayer-track-title-${paneIndex}`);
if (!dockedVid) return;
if (mediaplayerState.currentIndex >= 0 && mediaplayerState.playlist[mediaplayerState.currentIndex]) {
const track = mediaplayerState.playlist[mediaplayerState.currentIndex];
if (dockedTitle) dockedTitle.textContent = track.name || track.path.split('/').pop();
dockedVid.src = getDownloadUrl(track.path, true, track.paneIndex);
dockedVid.playbackRate = mediaplayerState.playbackRate;
dockedVid.volume = mediaplayerState.isMuted ? 0 : mediaplayerState.volume;
if (mediaplayerState.isPlaying) dockedVid.play().catch(() => {});
}
attachMediaEvents();
if (window.lucide) {
try { lucide.createIcons({ root: host ? host.parentElement : document }); } catch (e) {}
}
}
let mediaOsdTimer = null;
function showMediaOSD(iconName, text) {
const osd = document.getElementById('mediaplayer-osd');
const iconEl = document.getElementById('mediaplayer-osd-icon');
const textEl = document.getElementById('mediaplayer-osd-text');
if (!osd || !iconEl || !textEl) return;
if (iconName) {
iconEl.setAttribute('data-lucide', iconName);
iconEl.style.display = 'block';
} else {
iconEl.style.display = 'none';
}
textEl.textContent = text || '';
osd.style.display = 'flex';
if (window.lucide) lucide.createIcons();
if (mediaOsdTimer) clearTimeout(mediaOsdTimer);
mediaOsdTimer = setTimeout(() => {
osd.style.display = 'none';
}, 750);
}
function initMediaControlsAutohide() {
const stage = document.getElementById('mediaplayer-stage');
const win = document.getElementById('floating-mediaplayer-window');
if (!stage || !win) return;
stage.addEventListener('mousemove', () => {
stage.classList.remove('hide-controls');
win.classList.add('controls-visible');
if (mediaplayerState.controlsTimeout) clearTimeout(mediaplayerState.controlsTimeout);
if (mediaplayerState.isPlaying) {
mediaplayerState.controlsTimeout = setTimeout(() => {
stage.classList.add('hide-controls');
win.classList.remove('controls-visible');
}, 2500);
}
});
stage.addEventListener('mouseleave', () => {
if (mediaplayerState.isPlaying) {
stage.classList.add('hide-controls');
win.classList.remove('controls-visible');
}
});
}
function handleMediaScrubberHover(e) {
const el = getActiveMediaElement();
const track = document.getElementById('mediaplayer-scrubber-track');
const tooltip = document.getElementById('mediaplayer-scrubber-tooltip');
if (!el || !track || !tooltip || !el.duration) return;
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const pct = x / rect.width;
const hoverSecs = pct * el.duration;
tooltip.style.left = `${x}px`;
tooltip.textContent = formatMediaTime(hoverSecs);
tooltip.style.display = 'block';
}
function hideMediaScrubberTooltip() {
const tooltip = document.getElementById('mediaplayer-scrubber-tooltip');
if (tooltip) tooltip.style.display = 'none';
}
function initMediaplayerKeyboardShortcuts() {
if (mediaplayerKeysInitialized) return;
mediaplayerKeysInitialized = true;
window.addEventListener('keydown', (e) => {
const win = document.getElementById('floating-mediaplayer-window');
if (!win || win.style.display === 'none') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.code === 'Space' || e.key === 'k' || e.key === 'K') {
e.preventDefault();
toggleMediaPlay();
} else if (e.key === 'ArrowLeft' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
skipMedia(-10);
} else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
e.preventDefault();
skipMedia(10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
changeMediaVolume(Math.min(1, mediaplayerState.volume + 0.05));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
changeMediaVolume(Math.max(0, mediaplayerState.volume - 0.05));
} else if (e.key === 'm' || e.key === 'M') {
e.preventDefault();
toggleMediaMute();
} else if (e.key === 'f' || e.key === 'F') {
e.preventDefault();
toggleMediaFullscreen();
} else if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
toggleMediaPiP();
} else if (e.key === 's' || e.key === 'S') {
e.preventDefault();
cycleMediaSubtitles();
} else if (e.key === '>') {
e.preventDefault();
cycleMediaSpeed();
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
navMediaPlaylist(1);
} else if (e.key === 'Escape' && mediaplayerState.isFullscreen) {
toggleMediaFullscreen();
}
});
}
// Comic Book (.cbz/.cbr) & EPUB Reader
let bookPages = [];
let bookPageIndex = 0;
let bookZoom = 1;
let currentBookPath = '';
async function openBookReader(filePath) {
currentBookPath = filePath;
bookPages = [];
bookPageIndex = 0;
bookZoom = 1;
const fileName = filePath.split('/').pop() || filePath;
const titleEl = document.getElementById('book-reader-title');
if (titleEl) titleEl.textContent = fileName;
showModal('book-reader-modal');
// Query archive directory listing to get all image pages inside .cbz/.zip/.epub
try {
const resp = await fetch(`/api/fs/list?path=${encodeURIComponent('archive://' + filePath + '#')}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
bookPages = (data.entries || [])
.filter(e => !e.is_dir && isImageExtension(e.name))
.map(e => e.path);
if (bookPages.length === 0) {
const textEntries = (data.entries || []).filter(e => !e.is_dir);
if (textEntries.length > 0) {
loadBookTextEntry(textEntries[0].path);
return;
}
}
}
} catch (err) {
console.error('Book reader error:', err);
}
if (bookPages.length === 0) {
bookPages = [filePath];
}
loadBookPage(0);
}
async function loadBookPage(idx) {
if (bookPages.length === 0) return;
bookPageIndex = Math.max(0, Math.min(bookPages.length - 1, idx));
const counterEl = document.getElementById('book-reader-counter');
const imgEl = document.getElementById('book-reader-img');
const textEl = document.getElementById('book-reader-text');
if (counterEl) counterEl.textContent = `Page ${bookPageIndex + 1} / ${bookPages.length}`;
if (textEl) textEl.style.display = 'none';
if (imgEl) {
imgEl.style.display = 'block';
imgEl.style.transform = `scale(${bookZoom})`;
const p = bookPages[bookPageIndex];
if (p.startsWith('archive://')) {
try {
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(p)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const res = await resp.json();
if (res.is_binary) {
imgEl.src = `data:${res.mime_type || 'image/jpeg'};base64,${res.content}`;
} else {
imgEl.src = res.content;
}
}
} catch (err) {
console.error('Failed to load book page data:', err);
}
} else {
imgEl.src = getDownloadUrl(p, true);
}
}
}
async function loadBookTextEntry(path) {
const imgEl = document.getElementById('book-reader-img');
const textEl = document.getElementById('book-reader-text');
const counterEl = document.getElementById('book-reader-counter');
if (imgEl) imgEl.style.display = 'none';
if (counterEl) counterEl.textContent = 'Text Mode';
if (textEl) {
textEl.style.display = 'block';
try {
const resp = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const res = await resp.json();
textEl.textContent = res.content;
}
} catch (err) {
textEl.textContent = `Failed to read text: ${err}`;
}
}
}
function navBookPage(dir) {
if (bookPages.length <= 1) return;
loadBookPage(bookPageIndex + dir);
}
function zoomBookPage(delta) {
bookZoom = Math.max(0.4, Math.min(3.0, bookZoom + delta));
const imgEl = document.getElementById('book-reader-img');
if (imgEl) imgEl.style.transform = `scale(${bookZoom})`;
}
// ---------------- ADVANCED BULK RENAMER ----------------
let bulkRenameFiles = [];
let bulkRenamePreviewMap = [];
function triggerBulkRename() {
const pane = App.panes[App.activePaneIndex];
bulkRenameFiles = pane.selected.size > 0
? Array.from(pane.selected).map(p => pane.entries.find(e => e.path === p)).filter(Boolean)
: (pane.entries[pane.cursorIndex] ? [pane.entries[pane.cursorIndex]] : []);
if (bulkRenameFiles.length === 0) {
showToast('Please select one or more files to rename.', 'warning');
return;
}
updateBulkRenameUI();
updateBulkRenamePreview();
showModal('bulk-rename-modal');
}
function updateBulkRenameUI() {
const mode = document.getElementById('bulk-rename-mode').value;
['replace', 'sequence', 'prefix-suffix', 'case', 'extension'].forEach(m => {
const el = document.getElementById(`rule-mode-${m}`);
if (el) el.style.display = (m === mode) ? 'flex' : 'none';
});
updateBulkRenamePreview();
}
function updateBulkRenamePreview() {
const mode = document.getElementById('bulk-rename-mode').value;
bulkRenamePreviewMap = [];
const seenNewNames = new Set();
let hasConflict = false;
bulkRenameFiles.forEach((file, idx) => {
const origName = file.name;
const parts = origName.split('.');
const ext = parts.length > 1 ? parts.pop() : '';
const base = parts.join('.');
let newName = origName;
if (mode === 'replace') {
const findText = document.getElementById('rename-find').value;
const replaceText = document.getElementById('rename-replace').value;
const matchCase = document.getElementById('rename-match-case').checked;
const useRegex = document.getElementById('rename-use-regex').checked;
if (findText) {
try {
if (useRegex) {
const re = new RegExp(findText, matchCase ? 'g' : 'gi');
newName = origName.replace(re, replaceText);
} else {
if (matchCase) {
newName = origName.split(findText).join(replaceText);
} else {
const re = new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
newName = origName.replace(re, replaceText);
}
}
} catch (e) {
// Invalid regex, keep origName
}
}
} else if (mode === 'sequence') {
const seqBase = document.getElementById('rename-seq-base').value;
const start = parseInt(document.getElementById('rename-seq-start').value || '1', 10);
const step = parseInt(document.getElementById('rename-seq-step').value || '1', 10);
const pad = parseInt(document.getElementById('rename-seq-pad').value || '3', 10);
const num = String(start + (idx * step)).padStart(pad, '0');
newName = ext ? `${seqBase}${num}.${ext}` : `${seqBase}${num}`;
} else if (mode === 'prefix-suffix') {
const prefix = document.getElementById('rename-prefix').value || '';
const suffix = document.getElementById('rename-suffix').value || '';
newName = ext ? `${prefix}${base}${suffix}.${ext}` : `${prefix}${base}${suffix}`;
} else if (mode === 'case') {
const style = document.getElementById('rename-case-style').value;
if (style === 'lower') {
newName = origName.toLowerCase();
} else if (style === 'upper') {
newName = origName.toUpperCase();
} else if (style === 'title') {
newName = origName.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
} else if (style === 'kebab') {
newName = (base.toLowerCase().replace(/[\s_]+/g, '-') + (ext ? `.${ext.toLowerCase()}` : ''));
} else if (style === 'snake') {
newName = (base.toLowerCase().replace(/[\s-]+/g, '_') + (ext ? `.${ext.toLowerCase()}` : ''));
}
} else if (mode === 'extension') {
const newExt = document.getElementById('rename-new-ext').value.trim();
const lower = document.getElementById('rename-ext-lower').checked;
if (newExt) {
newName = `${base}.${lower ? newExt.toLowerCase() : newExt}`;
} else if (lower && ext) {
newName = `${base}.${ext.toLowerCase()}`;
}
}
const isDuplicate = seenNewNames.has(newName);
seenNewNames.add(newName);
if (isDuplicate) hasConflict = true;
const parentDir = file.path.substring(0, file.path.lastIndexOf('/'));
const newPath = `${parentDir}/${newName}`;
bulkRenamePreviewMap.push({
origName,
newName,
from: file.path,
to: newPath,
hasConflict: isDuplicate,
changed: origName !== newName
});
});
renderBulkRenameTable(hasConflict);
}
function renderBulkRenameTable(hasConflict) {
const tbody = document.getElementById('bulk-rename-tbody');
const stats = document.getElementById('bulk-rename-stats');
const btn = document.getElementById('btn-execute-bulk-rename');
if (!tbody) return;
const changedCount = bulkRenamePreviewMap.filter(i => i.changed).length;
stats.innerHTML = hasConflict
? `<span style="color:var(--danger); font-weight:700;">⚠️ Name Collision / Duplicate Detected</span>`
: `<span style="color:var(--success); font-weight:600;">${changedCount} of ${bulkRenamePreviewMap.length} items will be renamed</span>`;
btn.disabled = hasConflict || changedCount === 0;
tbody.innerHTML = bulkRenamePreviewMap.map(item => `
<tr class="file-row">
<td>
${item.hasConflict
? `<span class="diff-tag-conflict">Conflict</span>`
: (item.changed ? `<span class="diff-tag-new">✓ Change</span>` : `<span style="color:var(--text-dim); font-size:10px;">Unchanged</span>`)}
</td>
<td class="file-cell-mono">${escapeHtml(item.origName)}</td>
<td class="file-cell-mono ${item.changed ? 'diff-tag-new' : ''}">${escapeHtml(item.newName)}</td>
</tr>
`).join('');
}
async function executeBulkRename() {
const renames = bulkRenamePreviewMap.filter(i => i.changed).map(i => ({
from: i.from,
to: i.to
}));
if (renames.length === 0) {
closeModal('bulk-rename-modal');
return;
}
const resp = await fetch('/api/fs/batch-rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ renames })
});
if (resp.ok) {
showToast(`Renamed ${renames.length} file(s)`, 'success');
closeModal('bulk-rename-modal');
refreshAllPanes();
} else {
showToast('Batch Rename failed: ' + await resp.text(), 'error');
}
}
// ---------------- TWO-WAY VISUAL DIRECTORY SYNCHRONIZER ----------------
let syncAnalysisData = null;
let syncCurrentFilter = 'all';
let syncCustomExclusions = [];
function openSyncModal() {
const visible = getVisiblePaneCount();
const srcPane = App.panes[App.activePaneIndex];
const destPane = App.panes[(App.activePaneIndex + 1) % visible] || srcPane;
const srcIn = document.getElementById('sync-src-input');
const destIn = document.getElementById('sync-dest-input');
if (srcIn) srcIn.value = srcPane.path;
if (destIn) destIn.value = destPane.path;
syncAnalysisData = null;
syncCurrentFilter = 'all';
updateSyncCounters(0, 0, 0, 0, 0, 0);
const tbody = document.getElementById('sync-diff-body');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--text-dim);">Click <b>Compare & Analyze</b> or <b>Dry Run</b> to inspect differences.</td></tr>`;
const footerStats = document.getElementById('sync-footer-stats');
if (footerStats) footerStats.textContent = 'Ready to compare directories.';
handleSyncModeChange();
switchSyncTab('diff');
showModal('sync-modal');
if (window.lucide) lucide.createIcons();
}
function switchSyncTab(tab) {
document.querySelectorAll('.sync-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.sync-tab-pane').forEach(p => p.style.display = 'none');
const btn = document.getElementById(`sync-tab-btn-${tab}`);
const pane = document.getElementById(`sync-tab-content-${tab}`);
if (btn) btn.classList.add('active');
if (pane) pane.style.display = 'flex';
if (tab === 'profiles') {
loadBackupProfiles();
} else if (tab === 'history') {
loadBackupHistory();
}
if (window.lucide) lucide.createIcons();
}
function useActivePanePath(inputId) {
const activePane = App.panes[App.activePaneIndex];
if (activePane && activePane.path) {
const input = document.getElementById(inputId);
if (input) {
input.value = activePane.path;
showToast(`Set to Active Pane: ${activePane.path}`, 'info');
}
}
}
function useInactivePanePath(inputId) {
const otherIdx = (App.activePaneIndex === 0) ? 1 : 0;
const otherPane = App.panes[otherIdx] || App.panes[0];
if (otherPane && otherPane.path) {
const input = document.getElementById(inputId);
if (input) {
input.value = otherPane.path;
showToast(`Set to Target Pane: ${otherPane.path}`, 'info');
}
}
}
function swapSyncPaths() {
const srcIn = document.getElementById('sync-src-input');
const destIn = document.getElementById('sync-dest-input');
if (srcIn && destIn) {
const tmp = srcIn.value;
srcIn.value = destIn.value;
destIn.value = tmp;
showToast('Swapped Source and Destination paths', 'info');
}
}
function selectSyncMode(modeVal) {
const radio = document.querySelector(`input[name="sync-mode"][value="${modeVal}"]`);
if (radio) {
radio.checked = true;
handleSyncModeChange();
}
}
function handleSyncModeChange() {
const mode = document.querySelector('input[name="sync-mode"]:checked')?.value || 'synchronize';
document.querySelectorAll('.sync-profile-card').forEach(card => {
const radio = card.querySelector('input[type="radio"]');
card.classList.toggle('active', radio && radio.checked);
});
const archiveSettings = document.getElementById('sync-archive-settings');
if (archiveSettings) {
archiveSettings.style.display = (mode === 'subscribe') ? 'flex' : 'none';
}
}
let syncFolderPickerTargetInput = 'sync-src-input';
let syncPickerCurrentPath = '/';
function openSyncFolderPicker(targetInputId) {
syncFolderPickerTargetInput = targetInputId || 'sync-src-input';
const curVal = document.getElementById(syncFolderPickerTargetInput)?.value.trim() || getUserDefaultHomeDir() || '/';
syncPickerCurrentPath = curVal;
const panesList = document.getElementById('sync-picker-panes-list');
if (panesList) {
panesList.innerHTML = App.panes.map((p, idx) => `
<div class="sync-folder-picker-item" onclick="selectSyncPickerLocation('${escapeHtml(p.path || '/')}')">
<span style="font-weight: 600;"><i data-lucide="layout" style="width: 12px; height: 12px; vertical-align: -1px;"></i> Pane ${idx + 1} (${p.nodeId || 'local'})</span>
<span style="font-family: var(--font-mono); color: var(--text-muted); font-size: 11px;">${escapeHtml(p.path || '/')}</span>
</div>
`).join('');
}
navigateSyncPickerPath(syncPickerCurrentPath);
showModal('sync-folder-picker-modal');
if (window.lucide) lucide.createIcons();
}
function selectSyncPickerLocation(path) {
navigateSyncPickerPath(path);
}
async function navigateSyncPickerPath(newPath) {
if (!newPath) newPath = '/';
syncPickerCurrentPath = newPath;
const pathIn = document.getElementById('sync-picker-path-input');
const selLabel = document.getElementById('sync-picker-selected-label');
if (pathIn) pathIn.value = newPath;
if (selLabel) selLabel.textContent = `Selected: ${newPath}`;
const container = document.getElementById('sync-picker-subfolders');
if (container) {
container.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--accent);"><i data-lucide="loader" class="spinner"></i> Loading folders...</div>';
if (window.lucide) lucide.createIcons();
}
try {
const encoded = encodeURIComponent(newPath);
const resp = await fetch(`/api/files/list?path=${encoded}&dirs_only=true`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
const dirs = (data.entries || []).filter(e => e.is_dir && e.name !== '.' && e.name !== '..');
if (dirs.length === 0) {
if (container) container.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-dim); font-size: 11px;">No subfolders in this directory.</div>';
} else {
if (container) {
container.innerHTML = dirs.map(d => {
const fullSubPath = newPath.endsWith('/') ? `${newPath}${d.name}` : `${newPath}/${d.name}`;
return `
<div class="sync-folder-picker-item" onclick="navigateSyncPickerPath('${escapeHtml(fullSubPath)}')">
<span style="display: flex; align-items: center; gap: 6px;">
<i data-lucide="folder" style="width: 13px; height: 13px; color: var(--accent);"></i>
<span>${escapeHtml(d.name)}</span>
</span>
<span style="font-size: 10px; color: var(--text-dim);">Folder</span>
</div>
`;
}).join('');
}
}
} else {
if (container) container.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--danger); font-size: 11px;">Cannot open folder: ${escapeHtml(await resp.text())}</div>`;
}
} catch (err) {
if (container) container.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--danger); font-size: 11px;">Network error: ${escapeHtml(String(err))}</div>`;
}
if (window.lucide) lucide.createIcons();
}
function navigateSyncPickerUp() {
const parent = getParentDirectory(syncPickerCurrentPath);
if (parent && parent !== syncPickerCurrentPath) {
navigateSyncPickerPath(parent);
}
}
function confirmSyncFolderSelection() {
const input = document.getElementById(syncFolderPickerTargetInput);
if (input) {
input.value = syncPickerCurrentPath;
}
closeModal('sync-folder-picker-modal');
showToast(`Folder selected: ${syncPickerCurrentPath}`, 'info');
}
function toggleSyncExclusionsPanel() {
const panel = document.getElementById('sync-exclusions-panel');
if (!panel) return;
const isHidden = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = isHidden ? 'flex' : 'none';
if (isHidden && window.lucide) lucide.createIcons();
}
function toggleExclusionChip(chipEl) {
if (!chipEl) return;
chipEl.classList.toggle('active');
updateSyncExclusionBadge();
}
function addCustomExclusionPattern() {
const input = document.getElementById('sync-custom-exclusion-input');
if (!input) return;
const val = input.value.trim();
if (!val) return;
if (!syncCustomExclusions.includes(val)) {
syncCustomExclusions.push(val);
renderCustomExclusionTags();
updateSyncExclusionBadge();
}
input.value = '';
}
function removeCustomExclusionTag(pattern) {
syncCustomExclusions = syncCustomExclusions.filter(p => p !== pattern);
renderCustomExclusionTags();
updateSyncExclusionBadge();
}
function renderCustomExclusionTags() {
const container = document.getElementById('sync-active-custom-tags');
if (!container) return;
container.innerHTML = syncCustomExclusions.map(pat => `
<span class="sync-custom-tag">
${escapeHtml(pat)}
<span class="sync-custom-tag-remove" onclick="removeCustomExclusionTag('${escapeHtml(pat)}')">×</span>
</span>
`).join('');
}
function clearAllSyncExclusions() {
document.querySelectorAll('#sync-exclusions-panel .sync-chip').forEach(c => c.classList.remove('active'));
syncCustomExclusions = [];
renderCustomExclusionTags();
updateSyncExclusionBadge();
}
function getActiveSyncExclusions() {
const activeChips = Array.from(document.querySelectorAll('#sync-exclusions-panel .sync-chip.active'))
.map(c => c.getAttribute('data-pattern'))
.filter(Boolean);
return Array.from(new Set([...activeChips, ...syncCustomExclusions]));
}
function updateSyncExclusionBadge() {
const list = getActiveSyncExclusions();
const badge = document.getElementById('sync-exclusions-badge');
if (badge) badge.textContent = list.length;
const btn = document.getElementById('btn-toggle-sync-exclusions');
if (btn) {
if (list.length > 0) {
btn.classList.add('btn-accent');
} else {
btn.classList.remove('btn-accent');
}
}
}
function setPresetChips(patternsToActivate) {
document.querySelectorAll('#sync-exclusions-panel .sync-chip').forEach(chip => {
const pat = chip.getAttribute('data-pattern');
if (pat && patternsToActivate.includes(pat)) {
chip.classList.add('active');
} else {
chip.classList.remove('active');
}
});
updateSyncExclusionBadge();
}
function applySyncTemplate(templateKey) {
if (templateKey === 'nas_mirror') {
const radio = document.querySelector('input[name="sync-mode"][value="echo"]');
if (radio) { radio.checked = true; handleSyncModeChange(); }
const delta = document.getElementById('sync-opt-delta');
if (delta) delta.checked = true;
const verify = document.getElementById('sync-opt-verify');
if (verify) verify.checked = true;
setPresetChips(['.DS_Store', 'Thumbs.db', '*.tmp', '*.bak']);
showToast('🪞 Applied NAS Mirror preset (Echo mode, checksum verification, system exclusions)', 'info');
} else if (templateKey === 'code_sync') {
const radio = document.querySelector('input[name="sync-mode"][value="synchronize"]');
if (radio) { radio.checked = true; handleSyncModeChange(); }
const delta = document.getElementById('sync-opt-delta');
if (delta) delta.checked = true;
const verify = document.getElementById('sync-opt-verify');
if (verify) verify.checked = true;
setPresetChips(['node_modules', '.git', 'target', '.cache', 'tmp', 'dist', '*.log']);
showToast('💻 Applied Codebase Sync preset (Two-Way delta sync, developer exclusions)', 'info');
} else if (templateKey === 'snapshot_archive') {
const radio = document.querySelector('input[name="sync-mode"][value="subscribe"]');
if (radio) { radio.checked = true; handleSyncModeChange(); }
const delta = document.getElementById('sync-opt-delta');
if (delta) delta.checked = true;
const verify = document.getElementById('sync-opt-verify');
if (verify) verify.checked = true;
const retention = document.getElementById('sync-opt-retention');
if (retention) retention.value = '30';
setPresetChips(['.DS_Store', 'Thumbs.db', '*.tmp']);
showToast('📦 Applied Snapshot Vault preset (30-day versioned archive snapshots)', 'info');
} else if (templateKey === 'media_vault') {
const radio = document.querySelector('input[name="sync-mode"][value="contribute"]');
if (radio) { radio.checked = true; handleSyncModeChange(); }
const delta = document.getElementById('sync-opt-delta');
if (delta) delta.checked = true;
const verify = document.getElementById('sync-opt-verify');
if (verify) verify.checked = true;
setPresetChips(['.DS_Store', 'Thumbs.db', '*.tmp', '*.bak']);
showToast('📸 Applied Media Backup preset (Additive contribute mode, never delete from target)', 'info');
}
}
function applyJobModalTemplate(templateKey) {
if (!templateKey) return;
const nameInput = document.getElementById('bp-name');
const modeSelect = document.getElementById('bp-mode');
const schedSelect = document.getElementById('bp-schedule-type');
const intervalInput = document.getElementById('bp-interval-mins');
const dailyInput = document.getElementById('bp-daily-time');
const exclusionsInput = document.getElementById('bp-exclusions');
if (templateKey === 'nas_mirror') {
if (nameInput && !nameInput.value) nameInput.value = 'Daily NAS Mirror';
if (modeSelect) modeSelect.value = 'echo';
if (schedSelect) schedSelect.value = 'daily';
if (dailyInput) dailyInput.value = '02:00';
if (exclusionsInput) exclusionsInput.value = '.DS_Store, Thumbs.db, *.tmp, *.bak';
} else if (templateKey === 'code_sync') {
if (nameInput && !nameInput.value) nameInput.value = 'Codebase Interval Sync';
if (modeSelect) modeSelect.value = 'synchronize';
if (schedSelect) schedSelect.value = 'interval';
if (intervalInput) intervalInput.value = '30';
if (exclusionsInput) exclusionsInput.value = 'node_modules, .git, target, .cache, dist, *.log';
} else if (templateKey === 'snapshot_archive') {
if (nameInput && !nameInput.value) nameInput.value = 'Daily Snapshot Vault';
if (modeSelect) modeSelect.value = 'subscribe';
if (schedSelect) schedSelect.value = 'daily';
if (dailyInput) dailyInput.value = '03:00';
if (exclusionsInput) exclusionsInput.value = '.DS_Store, Thumbs.db, *.tmp';
} else if (templateKey === 'media_vault') {
if (nameInput && !nameInput.value) nameInput.value = 'Hourly Media Vault';
if (modeSelect) modeSelect.value = 'contribute';
if (schedSelect) schedSelect.value = 'interval';
if (intervalInput) intervalInput.value = '60';
if (exclusionsInput) exclusionsInput.value = '.DS_Store, Thumbs.db, *.tmp, *.bak';
}
handleScheduleTypeChange();
}
function appendBpExclusion(pattern) {
const input = document.getElementById('bp-exclusions');
if (!input) return;
const current = input.value.split(',').map(s => s.trim()).filter(Boolean);
if (!current.includes(pattern)) {
current.push(pattern);
input.value = current.join(', ');
}
}
function updateSyncCounters(all, left, right, mod, archive, eq) {
const cAll = document.getElementById('sync-cnt-all');
const cLeft = document.getElementById('sync-cnt-left');
const cRight = document.getElementById('sync-cnt-right');
const cMod = document.getElementById('sync-cnt-mod');
const cArchive = document.getElementById('sync-cnt-archive');
const cEq = document.getElementById('sync-cnt-eq');
if (cAll) cAll.textContent = all;
if (cLeft) cLeft.textContent = left;
if (cRight) cRight.textContent = right;
if (cMod) cMod.textContent = mod;
if (cArchive) cArchive.textContent = archive;
if (cEq) cEq.textContent = eq;
}
function filterSyncGrid(mode) {
syncCurrentFilter = mode;
document.querySelectorAll('.sync-filter-btn').forEach(b => b.classList.remove('active'));
const btnMap = {
'all': 'btn-sync-filter-all',
'left': 'btn-sync-filter-left',
'right': 'btn-sync-filter-right',
'modified': 'btn-sync-filter-mod',
'archive': 'btn-sync-filter-archive',
'identical': 'btn-sync-filter-eq'
};
const activeBtn = document.getElementById(btnMap[mode] || 'btn-sync-filter-all');
if (activeBtn) activeBtn.classList.add('active');
renderSyncDiffTable();
}
async function executeDryRun() {
const source = document.getElementById('sync-src-input')?.value.trim();
const destination = document.getElementById('sync-dest-input')?.value.trim();
if (!source || !destination) {
showToast('Please specify source and destination directories first', 'warning');
return;
}
await analyzeSync(true);
}
async function analyzeSync(isDryRun = false) {
const source = document.getElementById('sync-src-input')?.value.trim();
const destination = document.getElementById('sync-dest-input')?.value.trim();
const mode = document.querySelector('input[name="sync-mode"]:checked')?.value || 'synchronize';
const blockDelta = document.getElementById('sync-opt-delta')?.checked ?? true;
const verifyChecksum = document.getElementById('sync-opt-verify')?.checked ?? true;
const archiveDir = document.getElementById('sync-opt-archive-dir')?.value.trim() || '_archive';
const retentionDays = parseInt(document.getElementById('sync-opt-retention')?.value || '30', 10);
const exclusions = getActiveSyncExclusions();
if (!source || !destination) {
showToast('Please specify source and destination directories', 'warning');
return;
}
const tbody = document.getElementById('sync-diff-body');
const footerStats = document.getElementById('sync-footer-stats');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--accent);"><i data-lucide="loader" class="spinner"></i> ${isDryRun ? 'Simulating dry run & checking differences...' : 'Analyzing directory differences & delta blocks...'}</td></tr>`;
if (window.lucide) lucide.createIcons();
try {
const resp = await fetch('/api/tools/sync/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
source,
destination,
options: {
mode,
dry_run: true,
verify_checksum: verifyChecksum,
block_delta: blockDelta,
delete_orphans: mode === 'echo',
archive_dir: archiveDir,
retention_days: retentionDays,
exclusions,
}
})
});
if (!resp.ok) {
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--danger);">Analysis failed: ${escapeHtml(await resp.text())}</td></tr>`;
return;
}
syncAnalysisData = await resp.json();
const files = syncAnalysisData.files || [];
const leftCnt = files.filter(f => f.suggested_action === 'copy_right' || f.suggested_action === 'delta_update_right' || f.suggested_action === 'archive_and_update').length;
const rightCnt = files.filter(f => f.suggested_action === 'copy_left' || f.suggested_action === 'delta_update_left').length;
const modCnt = files.filter(f => f.suggested_action.startsWith('delta_update_') || f.status.startsWith('modified_')).length;
const archiveCnt = files.filter(f => f.suggested_action.startsWith('archive_')).length;
const eqCnt = files.filter(f => f.status === 'identical').length;
updateSyncCounters(files.length, leftCnt, rightCnt, modCnt, archiveCnt, eqCnt);
if (footerStats) {
const truncBadge = syncAnalysisData.is_truncated ? ' [First 10,000 files shown]' : '';
const savedStr = syncAnalysisData.bytes_saved_estimate > 0 ? ` • Est. Delta Savings: ~${formatFileSize(syncAnalysisData.bytes_saved_estimate)}` : '';
const dryPrefix = isDryRun ? '⚡ [Dry Run Simulation] ' : '';
footerStats.textContent = `${dryPrefix}Scanned ${files.length} items${truncBadge}: ${leftCnt} to copy/update ➔, ${rightCnt} to copy ⬅, ${archiveCnt} to archive 📦, ${eqCnt} identical (${formatFileSize(syncAnalysisData.total_transfer_bytes)} total)${savedStr}.`;
}
renderSyncDiffTable();
if (isDryRun) {
showToast(`Dry Run: ${leftCnt + rightCnt} to copy/update, ${archiveCnt} to archive, ${syncAnalysisData.to_delete?.length || 0} to delete`, 'info');
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--danger);">Error: ${escapeHtml(String(e))}</td></tr>`;
}
}
function toggleFileAction(index) {
if (!syncAnalysisData || !syncAnalysisData.files[index]) return;
const item = syncAnalysisData.files[index];
const cycle = ['copy_right', 'copy_left', 'delta_update_right', 'skip'];
const curIdx = cycle.indexOf(item.suggested_action);
item.suggested_action = cycle[(curIdx + 1) % cycle.length];
renderSyncDiffTable();
}
function renderSyncDiffTable() {
const tbody = document.getElementById('sync-diff-body');
if (!tbody || !syncAnalysisData) return;
const files = syncAnalysisData.files || [];
let filtered = files;
if (syncCurrentFilter === 'left') {
filtered = files.filter(f => f.suggested_action === 'copy_right' || f.suggested_action === 'delta_update_right' || f.suggested_action === 'archive_and_update');
} else if (syncCurrentFilter === 'right') {
filtered = files.filter(f => f.suggested_action === 'copy_left' || f.suggested_action === 'delta_update_left');
} else if (syncCurrentFilter === 'modified') {
filtered = files.filter(f => f.suggested_action.startsWith('delta_update_') || f.status.startsWith('modified_'));
} else if (syncCurrentFilter === 'archive') {
filtered = files.filter(f => f.suggested_action.startsWith('archive_'));
} else if (syncCurrentFilter === 'identical') {
filtered = files.filter(f => f.status === 'identical');
}
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 40px; color: var(--text-dim);">No files match the selected filter.</td></tr>`;
return;
}
const maxRender = 500;
const renderSlice = filtered.slice(0, maxRender);
const rowsHtml = renderSlice.map(f => {
let actionBadge = '';
if (f.suggested_action === 'copy_right') {
actionBadge = `<span class="sync-action-badge action-copy-right" title="Copy Left ➔ Right (Click to change)" onclick="toggleFileAction(${files.indexOf(f)})">➔</span>`;
} else if (f.suggested_action === 'copy_left') {
actionBadge = `<span class="sync-action-badge action-copy-left" title="Copy Right ➔ Left (Click to change)" onclick="toggleFileAction(${files.indexOf(f)})">⬅</span>`;
} else if (f.suggested_action === 'delta_update_right') {
actionBadge = `<span class="sync-action-badge action-update-delta" title="⚡ Delta Patch ➔ Right (Click to change)" onclick="toggleFileAction(${files.indexOf(f)})">⚡➔</span>`;
} else if (f.suggested_action === 'delta_update_left') {
actionBadge = `<span class="sync-action-badge action-update-delta" title="⚡ Delta Patch ➔ Left (Click to change)" onclick="toggleFileAction(${files.indexOf(f)})">⚡⬅</span>`;
} else if (f.suggested_action === 'archive_and_update') {
actionBadge = `<span class="sync-action-badge action-archive" title="📦 Archive Old Version & Update" onclick="toggleFileAction(${files.indexOf(f)})">📦⚡</span>`;
} else if (f.suggested_action === 'archive_and_delete') {
actionBadge = `<span class="sync-action-badge action-archive" title="📦 Archive Orphan & Remove from Dest" onclick="toggleFileAction(${files.indexOf(f)})">📦✕</span>`;
} else if (f.suggested_action === 'delete_right') {
actionBadge = `<span class="sync-action-badge action-delete-right" title="Delete from Target (Mirror mode)" onclick="toggleFileAction(${files.indexOf(f)})">✕</span>`;
} else if (f.suggested_action === 'identical') {
actionBadge = `<span class="sync-action-badge action-identical" title="Identical File" onclick="toggleFileAction(${files.indexOf(f)})">✔</span>`;
} else {
actionBadge = `<span class="sync-action-badge action-skip" title="Skip File (Click to change)" onclick="toggleFileAction(${files.indexOf(f)})">⊘</span>`;
}
const leftFileClass = f.status === 'left_only' ? 'color: var(--info); font-weight: 600;' : (f.status === 'right_only' ? 'color: var(--text-dim);' : 'color: var(--text-main);');
const rightFileClass = f.status === 'right_only' ? 'color: #c084fc; font-weight: 600;' : (f.status === 'left_only' ? 'color: var(--text-dim);' : 'color: var(--text-main);');
return `
<tr>
<td style="text-align: right; color: var(--text-muted);">${escapeHtml(f.src_size_formatted)}</td>
<td style="${leftFileClass} text-overflow: ellipsis; overflow: hidden; max-width: 300px;" title="${escapeHtml(f.rel_path)}">
${escapeHtml(f.rel_path)}
</td>
<td style="text-align: center;">${actionBadge}</td>
<td style="${rightFileClass} text-overflow: ellipsis; overflow: hidden; max-width: 300px;" title="${escapeHtml(f.rel_path)}">
${escapeHtml(f.rel_path)}
</td>
<td style="text-align: left; color: var(--text-muted);">${escapeHtml(f.dest_size_formatted)}</td>
</tr>
`;
}).join('');
const truncNotice = filtered.length > maxRender
? `<tr><td colspan="5" style="text-align: center; padding: 12px; font-weight: 600; color: var(--accent); background: var(--bg-panel);">Showing first ${maxRender} of ${filtered.length} matching files. Use filters above to inspect specific changes.</td></tr>`
: '';
tbody.innerHTML = rowsHtml + truncNotice;
}
async function executeSync(dryRun = false) {
const source = document.getElementById('sync-src-input')?.value.trim();
const destination = document.getElementById('sync-dest-input')?.value.trim();
const mode = document.querySelector('input[name="sync-mode"]:checked')?.value || 'synchronize';
const blockDelta = document.getElementById('sync-opt-delta')?.checked ?? true;
const verifyChecksum = document.getElementById('sync-opt-verify')?.checked ?? true;
const archiveDir = document.getElementById('sync-opt-archive-dir')?.value.trim() || '_archive';
const retentionDays = parseInt(document.getElementById('sync-opt-retention')?.value || '30', 10);
const exclusions = getActiveSyncExclusions();
if (!source || !destination) {
showToast('Please specify source and destination directories', 'warning');
return;
}
if (dryRun) {
await executeDryRun();
return;
}
closeModal('sync-modal');
try {
const resp = await fetch('/api/tools/sync/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
source,
destination,
options: {
mode,
dry_run: false,
verify_checksum: verifyChecksum,
block_delta: blockDelta,
delete_orphans: mode === 'echo',
archive_dir: archiveDir,
retention_days: retentionDays,
exclusions,
}
})
});
if (resp.ok) {
const data = await resp.json();
const snapMsg = data.snapshot_dir ? ` (Snapshot: ${data.snapshot_dir})` : '';
showToast(`⚡ Replication completed: ${data.copied || 0} copied, ${data.archived || 0} archived, ${data.deleted || 0} deleted${snapMsg}`, 'success');
refreshAllPanes();
openFloatingTaskManager();
} else {
showToast('Sync error: ' + await resp.text(), 'error');
}
} catch (e) {
showToast('Sync execution failed: ' + String(e), 'error');
}
}
// ---------------- BACKUP PROFILES & SCHEDULER JS ----------------
function openSaveCurrentSyncAsProfile() {
const src = document.getElementById('sync-src-input')?.value.trim();
const dest = document.getElementById('sync-dest-input')?.value.trim();
const mode = document.querySelector('input[name="sync-mode"]:checked')?.value || 'synchronize';
openCreateBackupProfileModal({
id: '',
name: 'Backup Job ' + new Date().toLocaleDateString(),
source_dir: src,
dest_dir: dest,
profile_mode: mode,
exclusions: getActiveSyncExclusions().join(', '),
block_delta: document.getElementById('sync-opt-delta')?.checked ?? true,
verify_checksum: document.getElementById('sync-opt-verify')?.checked ?? true,
archive_dir: document.getElementById('sync-opt-archive-dir')?.value || '_archive',
retention_days: parseInt(document.getElementById('sync-opt-retention')?.value || '30', 10),
schedule_type: 'daily',
schedule_interval_mins: 60,
schedule_time: '02:00',
webhook_url: '',
enabled: true
});
}
function openCreateBackupProfileModal(profile) {
const p = profile || {};
const srcVal = p.source_dir || (App.panes && App.panes[0] ? App.panes[0].path : '');
const destVal = p.dest_dir || (App.panes && App.panes[1] ? App.panes[1].path : (App.panes && App.panes[0] ? App.panes[0].path : ''));
const idIn = document.getElementById('bp-id');
const nameIn = document.getElementById('bp-name');
const srcIn = document.getElementById('bp-source');
const destIn = document.getElementById('bp-dest');
const modeIn = document.getElementById('bp-mode');
const exclIn = document.getElementById('bp-exclusions');
const tmplSelect = document.getElementById('bp-template-select');
const schedIn = document.getElementById('bp-schedule-type');
const intervalIn = document.getElementById('bp-interval-mins');
const dailyIn = document.getElementById('bp-daily-time');
const deltaIn = document.getElementById('bp-block-delta');
const verifyIn = document.getElementById('bp-verify');
const enabledIn = document.getElementById('bp-enabled');
const webhookIn = document.getElementById('bp-webhook');
if (idIn) idIn.value = p.id || '';
if (nameIn) nameIn.value = p.name || '';
if (srcIn) srcIn.value = srcVal;
if (destIn) destIn.value = destVal;
if (modeIn) modeIn.value = p.profile_mode || 'subscribe';
if (exclIn) exclIn.value = p.exclusions || '';
if (tmplSelect) tmplSelect.value = '';
if (schedIn) schedIn.value = p.schedule_type || 'manual';
if (intervalIn) intervalIn.value = p.schedule_interval_mins || 60;
if (dailyIn) dailyIn.value = p.schedule_time || '02:00';
if (deltaIn) deltaIn.checked = p.block_delta ?? true;
if (verifyIn) verifyIn.checked = p.verify_checksum ?? true;
if (enabledIn) enabledIn.checked = p.enabled ?? true;
if (webhookIn) webhookIn.value = p.webhook_url || '';
const titleEl = document.getElementById('backup-profile-modal-title');
if (titleEl) titleEl.textContent = p.id ? 'Edit Backup Profile' : 'Create Backup Profile';
handleScheduleTypeChange();
showModal('backup-profile-modal');
if (window.lucide) lucide.createIcons();
}
function handleScheduleTypeChange() {
const type = document.getElementById('bp-schedule-type')?.value;
const intervalRow = document.getElementById('bp-interval-row');
if (intervalRow) {
intervalRow.style.display = (type === 'interval' || type === 'daily') ? 'grid' : 'none';
}
}
async function saveBackupProfileForm() {
const id = document.getElementById('bp-id')?.value.trim() || '';
const name = document.getElementById('bp-name')?.value.trim();
const source_dir = document.getElementById('bp-source')?.value.trim();
const dest_dir = document.getElementById('bp-dest')?.value.trim();
const profile_mode = document.getElementById('bp-mode')?.value || 'subscribe';
const exclusions = document.getElementById('bp-exclusions')?.value.trim() || null;
const schedule_type = document.getElementById('bp-schedule-type')?.value || 'manual';
const schedule_interval_mins = parseInt(document.getElementById('bp-interval-mins')?.value || '60', 10);
const schedule_time = document.getElementById('bp-daily-time')?.value.trim() || '02:00';
const block_delta = document.getElementById('bp-block-delta')?.checked ?? true;
const verify_checksum = document.getElementById('bp-verify')?.checked ?? true;
const enabled = document.getElementById('bp-enabled')?.checked ?? true;
const webhook_url = document.getElementById('bp-webhook')?.value.trim() || null;
if (!name || !source_dir || !dest_dir) {
showToast('Please provide a name, source directory, and destination directory', 'warning');
return;
}
try {
const resp = await fetch('/api/tools/sync/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
id,
name,
source_dir,
dest_dir,
profile_mode,
exclusions,
block_delta,
verify_checksum,
archive_dir: '_archive',
retention_days: 30,
schedule_type,
schedule_interval_mins,
schedule_time,
webhook_url,
enabled,
last_run: null,
last_status: null,
last_result: null,
created_at: 0
})
});
if (resp.ok) {
showToast('Backup profile saved successfully', 'success');
closeModal('backup-profile-modal');
loadBackupProfiles();
} else {
showToast('Failed to save profile: ' + await resp.text(), 'error');
}
} catch (e) {
showToast('Save error: ' + String(e), 'error');
}
}
let currentBackupProfiles = [];
function editBackupProfileByIndex(idx) {
if (currentBackupProfiles[idx]) {
openCreateBackupProfileModal(currentBackupProfiles[idx]);
}
}
async function loadBackupProfiles() {
const tbody = document.getElementById('backup-profiles-body');
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 30px; color: var(--accent);"><i data-lucide="loader"></i> Loading profiles...</td></tr>`;
if (window.lucide) lucide.createIcons();
try {
const resp = await fetch('/api/tools/sync/profiles', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 30px; color: var(--danger);">Failed to load profiles.</td></tr>`;
return;
}
const profiles = await resp.json();
currentBackupProfiles = profiles || [];
if (profiles.length === 0) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 40px; color: var(--text-dim);">No backup jobs configured yet. Click <b>New Backup Job</b> to create one.</td></tr>`;
return;
}
if (tbody) {
tbody.innerHTML = profiles.map((p, idx) => {
const modeBadge = {
'synchronize': '<span class="badge" style="background: rgba(59,130,246,0.15); color: #60a5fa;">2-Way Sync</span>',
'echo': '<span class="badge" style="background: rgba(239,68,68,0.15); color: #f87171;">Mirror (Echo)</span>',
'contribute': '<span class="badge" style="background: rgba(34,197,94,0.15); color: #4ade80;">Contribute</span>',
'subscribe': '<span class="badge" style="background: rgba(168,85,247,0.15); color: #c084fc;">Archive Versioning</span>'
}[p.profile_mode] || `<span class="badge">${escapeHtml(p.profile_mode)}</span>`;
let schedBadge = '';
if (p.schedule_type === 'realtime') {
schedBadge = '<span class="badge" style="background: rgba(245,158,11,0.2); color: #fbbf24;">⚡ Real-Time Watcher</span>';
} else if (p.schedule_type === 'interval') {
schedBadge = `<span class="badge" style="background: rgba(14,165,233,0.15); color: #38bdf8;">⏰ Every ${p.schedule_interval_mins}m</span>`;
} else if (p.schedule_type === 'daily') {
schedBadge = `<span class="badge" style="background: rgba(139,92,246,0.15); color: #a78bfa;">📅 Daily @ ${p.schedule_time || '02:00'}</span>`;
} else {
schedBadge = '<span class="badge" style="background: rgba(255,255,255,0.05); color: var(--text-dim);">Manual</span>';
}
const activeSwitch = `
<button class="btn btn-xs" onclick="toggleBackupProfileActive('${p.id}')" style="background: ${p.enabled ? 'rgba(34,197,94,0.2); color: #4ade80;' : 'rgba(255,255,255,0.05); color: var(--text-dim);'}">
${p.enabled ? '● Active' : '○ Paused'}
</button>
`;
const lastRunStr = p.last_run ? new Date(p.last_run * 1000).toLocaleString() : '<span style="color: var(--text-dim);">Never</span>';
const statusPill = p.last_status === 'success' ? '✓' : (p.last_status === 'failed' ? '❌' : '');
return `
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 8px 12px;">
<div style="font-weight: 600;">${escapeHtml(p.name)}</div>
${p.exclusions ? `<div style="font-size: 10px; color: var(--text-dim); margin-top: 2px;" title="Exclusions: ${escapeHtml(p.exclusions)}"><i data-lucide="filter" style="width: 10px; height: 10px; display: inline-block; vertical-align: middle;"></i> ${escapeHtml(p.exclusions)}</div>` : ''}
</td>
<td style="padding: 8px 12px; font-family: var(--font-mono); font-size: 11px; max-width: 280px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(p.source_dir)} ➔ ${escapeHtml(p.dest_dir)}">
<span style="color: var(--info);">${escapeHtml(p.source_dir)}</span> ➔ <span style="color: #c084fc;">${escapeHtml(p.dest_dir)}</span>
</td>
<td style="padding: 8px 10px; text-align: center;">${modeBadge}</td>
<td style="padding: 8px 10px; text-align: center;">${schedBadge}</td>
<td style="padding: 8px 10px; text-align: center;">${activeSwitch}</td>
<td style="padding: 8px 12px; font-size: 11px; color: var(--text-muted);">${statusPill} ${lastRunStr}</td>
<td style="padding: 8px 12px; text-align: right; white-space: nowrap;">
<button class="btn btn-xs btn-accent" onclick="runBackupProfileNow('${p.id}')" title="Run Backup Now"><i data-lucide="play"></i></button>
<button class="btn btn-xs" onclick="editBackupProfileByIndex(${idx})" title="Edit Profile"><i data-lucide="edit"></i></button>
<button class="btn btn-xs" onclick="deleteBackupProfile('${p.id}')" title="Delete Profile" style="color: var(--danger);"><i data-lucide="trash-2"></i></button>
</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 30px; color: var(--danger);">Error loading profiles: ${escapeHtml(String(e))}</td></tr>`;
}
}
async function runBackupProfileNow(id) {
try {
const resp = await fetch(`/api/tools/sync/profiles/${encodeURIComponent(id)}/run`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast('⚡ Backup job started in background', 'info');
openFloatingTaskManager();
} else {
showToast('Failed to start backup job: ' + await resp.text(), 'error');
}
} catch (e) {
showToast('Run error: ' + String(e), 'error');
}
}
async function toggleBackupProfileActive(id) {
try {
const resp = await fetch(`/api/tools/sync/profiles/${encodeURIComponent(id)}/toggle`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
loadBackupProfiles();
}
} catch (e) {
showToast('Toggle error: ' + String(e), 'error');
}
}
async function deleteBackupProfile(id) {
const confirmed = await showConfirmDialog({
title: 'Delete Backup Profile',
subtitle: 'Replication / Backup Job',
message: 'Are you sure you want to delete this backup profile? Scheduled tasks and automation for this profile will be removed.',
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete Profile'
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/tools/sync/profiles/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast('Backup profile deleted', 'info');
loadBackupProfiles();
} else {
showToast('Delete failed: ' + await resp.text(), 'error');
}
} catch (e) {
showToast('Delete error: ' + String(e), 'error');
}
}
async function loadBackupHistory() {
const tbody = document.getElementById('backup-history-body');
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 30px; color: var(--accent);"><i data-lucide="loader"></i> Loading history...</td></tr>`;
if (window.lucide) lucide.createIcons();
try {
const resp = await fetch('/api/tools/sync/history?limit=50', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 30px; color: var(--danger);">Failed to load history.</td></tr>`;
return;
}
const items = await resp.json();
if (items.length === 0) {
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 40px; color: var(--text-dim);">No backup executions recorded yet.</td></tr>`;
return;
}
if (tbody) {
tbody.innerHTML = items.map(h => {
const dateStr = new Date(h.started_at * 1000).toLocaleString();
const statusBadge = h.status === 'success'
? '<span class="badge" style="background: rgba(34,197,94,0.15); color: #4ade80;">Success</span>'
: '<span class="badge" style="background: rgba(239,68,68,0.15); color: #f87171;">Failed</span>';
const durSec = (h.duration_ms / 1000).toFixed(1) + 's';
const errDetails = h.error_message ? `<span style="color: var(--danger); font-size: 10px;">${escapeHtml(h.error_message)}</span>` : '<span style="color: var(--text-dim);">Clean execution</span>';
return `
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 8px 12px; font-size: 11px; color: var(--text-muted);">${dateStr}</td>
<td style="padding: 8px 12px; font-weight: 600;">${escapeHtml(h.profile_name)}</td>
<td style="padding: 8px 10px; text-align: center;">${statusBadge}</td>
<td style="padding: 8px 10px; text-align: right; color: #4ade80;">${h.files_copied}</td>
<td style="padding: 8px 10px; text-align: right; color: #c084fc;">${h.files_archived}</td>
<td style="padding: 8px 10px; text-align: right; color: #f87171;">${h.files_deleted}</td>
<td style="padding: 8px 10px; text-align: right; font-family: var(--font-mono); font-size: 11px;">${durSec}</td>
<td style="padding: 8px 12px; font-size: 11px;">${errDetails}</td>
</tr>
`;
}).join('');
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 30px; color: var(--danger);">Error loading history: ${escapeHtml(String(e))}</td></tr>`;
}
}
// ---------------- VISUAL DISK USAGE TREEMAP & BAR ANALYZER ----------------
let duCurrentData = null;
let duCurrentPath = '';
let duViewMode = 'split'; // 'split', 'treemap', 'list'
let duFilterQuery = '';
let duAbortController = null;
const DU_PALETTE = [
{ bg: 'linear-gradient(135deg, rgba(245, 158, 11, 0.28), rgba(217, 119, 6, 0.15))', border: '#f59e0b', bar: '#f59e0b', text: '#fbbf24' },
{ bg: 'linear-gradient(135deg, rgba(16, 185, 129, 0.28), rgba(5, 150, 105, 0.15))', border: '#10b981', bar: '#10b981', text: '#34d399' },
{ bg: 'linear-gradient(135deg, rgba(56, 189, 248, 0.28), rgba(2, 132, 199, 0.15))', border: '#38bdf8', bar: '#38bdf8', text: '#7dd3fc' },
{ bg: 'linear-gradient(135deg, rgba(168, 85, 247, 0.28), rgba(147, 51, 234, 0.15))', border: '#a855f7', bar: '#a855f7', text: '#c084fc' },
{ bg: 'linear-gradient(135deg, rgba(236, 72, 153, 0.28), rgba(219, 39, 119, 0.15))', border: '#ec4899', bar: '#ec4899', text: '#f472b6' },
{ bg: 'linear-gradient(135deg, rgba(249, 115, 22, 0.28), rgba(234, 88, 12, 0.15))', border: '#f97316', bar: '#f97316', text: '#fb923c' },
{ bg: 'linear-gradient(135deg, rgba(99, 102, 241, 0.28), rgba(79, 70, 229, 0.15))', border: '#6366f1', bar: '#6366f1', text: '#818cf8' },
{ bg: 'linear-gradient(135deg, rgba(20, 184, 166, 0.28), rgba(13, 148, 136, 0.15))', border: '#14b8a6', bar: '#14b8a6', text: '#2dd4bf' }
];
let duSystemDisks = [];
async function loadSystemDisksOverview(forceRefresh = false) {
const container = document.getElementById('du-mounts-cards');
const countEl = document.getElementById('du-mounts-count');
if (container && (!duSystemDisks || duSystemDisks.length === 0 || forceRefresh)) {
container.innerHTML = '<div style="padding: 24px; text-align: center; color: var(--accent); grid-column: 1 / -1;"><i data-lucide="loader-2" class="spin" style="width: 20px; height: 20px;"></i><div style="margin-top: 6px; font-size: 11.5px;">Querying mounted filesystems & drives...</div></div>';
if (window.lucide) lucide.createIcons();
}
try {
const resp = await fetch('/api/tools/disks', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const disks = await resp.json();
duSystemDisks = disks || [];
window._systemDisks = duSystemDisks;
renderSystemDisksOverview();
} catch (err) {
console.error('Failed to load system disks:', err);
if (container) {
container.innerHTML = `<div style="padding: 20px; text-align: center; color: #f87171; grid-column: 1 / -1;">Failed to load storage mounts: ${escapeHtml(err.message)}</div>`;
}
}
}
function renderSystemDisksOverview() {
const container = document.getElementById('du-mounts-cards');
const countEl = document.getElementById('du-mounts-count');
if (countEl) countEl.textContent = duSystemDisks.length.toString();
if (!container) return;
if (!duSystemDisks || duSystemDisks.length === 0) {
container.innerHTML = '<div style="padding: 24px; text-align: center; color: var(--text-dim); grid-column: 1 / -1;">No mounted filesystems detected.</div>';
return;
}
// Calculate summary stats across all disks
let totalCap = 0;
let totalUsed = 0;
let totalFree = 0;
duSystemDisks.forEach(d => {
totalCap += d.total_bytes || 0;
totalUsed += d.used_bytes || 0;
totalFree += d.available_bytes || 0;
});
const totalSpaceEl = document.getElementById('du-total-space');
const totalFilesEl = document.getElementById('du-total-files');
const totalDirsEl = document.getElementById('du-total-dirs');
const largestStatEl = document.getElementById('du-largest-stat');
if (duViewMode === 'mounts') {
if (totalSpaceEl) totalSpaceEl.textContent = formatFileSize(totalCap);
if (totalFilesEl) totalFilesEl.textContent = `${duSystemDisks.length} Mounts`;
if (totalDirsEl) totalDirsEl.textContent = formatFileSize(totalFree) + ' Free';
if (largestStatEl) {
const highestUsed = [...duSystemDisks].sort((a, b) => (b.usage_percentage || 0) - (a.usage_percentage || 0))[0];
largestStatEl.textContent = highestUsed ? `${highestUsed.name} (${highestUsed.usage_percentage}%)` : '-';
}
}
container.innerHTML = duSystemDisks.map(disk => {
const pct = disk.usage_percentage || 0;
const fillClass = pct > 90 ? 'high' : (pct > 70 ? 'mid' : 'low');
const isZfs = disk.fs_type.toLowerCase().includes('zfs');
const isBtrfs = disk.fs_type.toLowerCase().includes('btrfs');
const isNetwork = ['nfs', 'smb', 'cifs', 'sshfs', 'fuse'].some(t => disk.fs_type.toLowerCase().includes(t));
const iconName = isNetwork ? 'cloud' : (isZfs || isBtrfs ? 'database' : 'hard-drive');
return `
<div class="du-mount-card">
<div class="du-mount-header">
<div class="du-mount-title-group">
<div class="du-mount-icon">
<i data-lucide="${iconName}" style="width: 15px; height: 15px;"></i>
</div>
<div style="min-width: 0; flex: 1;">
<div class="du-mount-title" title="${escapeHtml(disk.name)}">${escapeHtml(disk.name)}</div>
<div class="du-mount-path" title="${escapeHtml(disk.mount_point)} • Device: ${escapeHtml(disk.device)}">
${escapeHtml(disk.mount_point)} <span style="opacity: 0.6;">(${escapeHtml(disk.device)})</span>
</div>
</div>
</div>
<div class="du-mount-badges">
<span class="du-mount-badge fstype">${escapeHtml(disk.fs_type)}</span>
<span class="du-mount-badge ${disk.is_read_only ? 'ro' : 'rw'}">${disk.is_read_only ? 'RO' : 'RW'}</span>
</div>
</div>
<div class="du-mount-meter-wrapper">
<div class="du-mount-meter-bar">
<div class="du-mount-meter-fill ${fillClass}" style="width: ${Math.min(100, Math.max(1, pct))}%;"></div>
</div>
<div class="du-mount-metrics">
<span><strong>${escapeHtml(disk.formatted_used)}</strong> used / ${escapeHtml(disk.formatted_total)}</span>
<span style="font-weight: 700; color: ${pct > 90 ? '#ef4444' : (pct > 70 ? 'var(--accent)' : '#10b981')};">
${escapeHtml(disk.formatted_available)} free (${pct}%)
</span>
</div>
</div>
<div class="du-mount-actions">
<button type="button" class="btn btn-xs btn-outline" style="flex: 1; height: 26px; padding: 0 6px; font-size: 10.5px; display: inline-flex; align-items: center; justify-content: center; gap: 4px;" onclick="jumpToPaneFromDiskUsage('${escapeHtml(disk.mount_point)}')" title="Open this mount in active panel">
<i data-lucide="folder-open" style="width: 12px; height: 12px;"></i> Open in Panel
</button>
<button type="button" class="btn btn-xs btn-accent" style="flex: 1; height: 26px; padding: 0 6px; font-size: 10.5px; display: inline-flex; align-items: center; justify-content: center; gap: 4px;" onclick="scanMountpointTreemap('${escapeHtml(disk.mount_point)}')" title="Run parallel Rayon deep scan on this mount point">
<i data-lucide="layout-grid" style="width: 12px; height: 12px;"></i> Deep Scan
</button>
<button type="button" class="btn btn-xs btn-outline" style="width: 26px; height: 26px; padding: 0; display: inline-flex; align-items: center; justify-content: center;" onclick="openTerminalFromDiskUsage('${escapeHtml(disk.mount_point)}')" title="Open Terminal here">
<i data-lucide="terminal" style="width: 12px; height: 12px;"></i>
</button>
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function scanMountpointTreemap(mountPath) {
setDiskUsageViewMode('treemap');
const pathIn = document.getElementById('du-path-input');
if (pathIn) pathIn.value = mountPath;
runDiskUsageScan(mountPath);
}
function openDiskUsageModal(path, optionalMode) {
const toolsMenu = document.getElementById('tools-dropdown-menu');
if (toolsMenu) toolsMenu.classList.remove('active');
const win = document.getElementById('floating-diskusage-window');
const body = document.getElementById('diskusage-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('diskusage-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initDiskUsageDragResize();
setupDiskUsageDropzone();
loadSystemDisksOverview();
if (optionalMode === 'mounts' || (!path && duViewMode === 'mounts')) {
setDiskUsageViewMode('mounts');
} else {
const targetPath = path || App.panes[App.activePaneIndex]?.path || '/';
if (duViewMode === 'mounts') {
setDiskUsageViewMode('split');
} else {
setDiskUsageViewMode(duViewMode || 'split');
}
runDiskUsageScan(targetPath);
}
}
function closeFloatingDiskUsage() {
const win = document.getElementById('floating-diskusage-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('diskusage-pill');
if (pill) pill.style.display = 'none';
}
function minimizeFloatingDiskUsage() {
const win = document.getElementById('floating-diskusage-window');
const pill = document.getElementById('diskusage-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingDiskUsage() {
openDiskUsageModal(duCurrentPath);
}
function toggleDiskUsageMaximize() {
const win = document.getElementById('floating-diskusage-window');
const btn = document.getElementById('du-maximize-btn');
if (!win) return;
win.classList.toggle('maximized');
const isMax = win.classList.contains('maximized');
if (btn) {
btn.innerHTML = isMax ? '<i data-lucide="minimize-2" style="width: 14px; height: 14px;"></i>' : '<i data-lucide="maximize-2" style="width: 14px; height: 14px;"></i>';
btn.title = isMax ? 'Restore Window' : 'Maximize Window';
if (window.lucide) lucide.createIcons();
}
}
function dockDiskUsageToActivePane() {
closeFloatingDiskUsage();
App.panes[App.activePaneIndex].dockedTool = 'diskusage';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'diskusage');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function mountDockedDiskUsage(paneIndex) {
const host = document.getElementById(`docked-diskusage-host-${paneIndex}`);
const floatingBody = document.getElementById('diskusage-body');
if (host && floatingBody) {
host.appendChild(floatingBody);
setupDiskUsageDropzone();
const p = App.panes[paneIndex]?.path || '/';
runDiskUsageScan(p);
if (window.lucide) lucide.createIcons();
}
}
function setupDiskUsageDropzone(customWin = null) {
const win = customWin || document.getElementById('floating-diskusage-window');
const body = document.getElementById('diskusage-body');
const targetElements = [win, body].filter(Boolean);
targetElements.forEach(el => {
if (el._duDropzoneBound) return;
el._duDropzoneBound = true;
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (win) win.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
if (!el.contains(e.relatedTarget)) {
if (win) win.classList.remove('drag-over');
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (win) win.classList.remove('drag-over');
let droppedPaths = [];
const plainText = e.dataTransfer.getData('text/plain');
if (plainText) {
try {
const data = JSON.parse(plainText);
if (data.paths && data.paths.length > 0) {
droppedPaths = data.paths;
}
} catch (_) {
if (plainText.startsWith('/') || plainText.startsWith('~')) {
droppedPaths = [plainText.trim()];
}
}
}
if (droppedPaths.length === 0 && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
droppedPaths = Array.from(e.dataTransfer.files).map(f => f.path).filter(Boolean);
}
if (droppedPaths.length === 0) return;
const targetPath = droppedPaths[0];
if (duViewMode === 'mounts') {
setDiskUsageViewMode('split');
}
const pathIn = document.getElementById('du-path-input');
if (pathIn) pathIn.value = targetPath;
runDiskUsageScan(targetPath);
});
});
}
function initDiskUsageDragResize() {
const win = document.getElementById('floating-diskusage-window');
const header = document.getElementById('disk-usage-header');
if (!win || !header) return;
const savedX = localStorage.getItem('cd_du_x');
const savedY = localStorage.getItem('cd_du_y');
const savedW = localStorage.getItem('cd_du_w');
const savedH = localStorage.getItem('cd_du_h');
if (window.innerWidth > 1024) {
if (savedW) win.style.width = `${Math.min(parseInt(savedW, 10), window.innerWidth - 20)}px`;
if (savedH) win.style.height = `${Math.min(parseInt(savedH, 10), window.innerHeight - 50)}px`;
if (savedX && savedY) {
const maxX = Math.max(10, window.innerWidth - (parseInt(savedW, 10) || 880) - 20);
const maxY = Math.max(40, window.innerHeight - (parseInt(savedH, 10) || 640) - 20);
win.style.left = `${Math.min(Math.max(10, parseInt(savedX, 10)), maxX)}px`;
win.style.top = `${Math.min(Math.max(40, parseInt(savedY, 10)), maxY)}px`;
}
}
if (header._duDragBound) return;
header._duDragBound = true;
let isDragging = false;
let startX = 0, startY = 0, initialLeft = 0, initialTop = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('button') || e.target.closest('input') || e.target.closest('select') || e.target.closest('a')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
bringFloatingWindowToFront(win);
e.preventDefault();
});
let isResizing = false;
let currentResizeDir = '';
let initialW = 0, initialH = 0;
const resizeHandles = [
{ el: document.getElementById('diskusage-resize-top'), dir: 'top' },
{ el: document.getElementById('diskusage-resize-bottom'), dir: 'bottom' },
{ el: document.getElementById('diskusage-resize-left'), dir: 'left' },
{ el: document.getElementById('diskusage-resize-right'), dir: 'right' },
{ el: document.getElementById('diskusage-resize-corner'), dir: 'corner' },
];
resizeHandles.forEach(({ el, dir }) => {
if (!el) return;
el.addEventListener('mousedown', (e) => {
if (win.classList.contains('maximized')) return;
isResizing = true;
currentResizeDir = dir;
startX = e.clientX;
startY = e.clientY;
const rect = win.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
initialW = rect.width;
initialH = rect.height;
document.body.style.userSelect = 'none';
if (dir === 'top' || dir === 'bottom') document.body.style.cursor = 'ns-resize';
else if (dir === 'left' || dir === 'right') document.body.style.cursor = 'ew-resize';
else document.body.style.cursor = 'nwse-resize';
bringFloatingWindowToFront(win);
e.preventDefault();
e.stopPropagation();
});
});
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, initialLeft + dx));
const newTop = Math.max(36, Math.min(window.innerHeight - win.offsetHeight, initialTop + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
}
if (isResizing) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (currentResizeDir === 'corner' || currentResizeDir === 'right') {
const newW = Math.max(480, Math.min(window.innerWidth - initialLeft - 10, initialW + dx));
win.style.width = `${newW}px`;
}
if (currentResizeDir === 'corner' || currentResizeDir === 'bottom') {
const newH = Math.max(400, Math.min(window.innerHeight - initialTop - 10, initialH + dy));
win.style.height = `${newH}px`;
}
if (currentResizeDir === 'left') {
const newW = Math.max(480, initialW - dx);
if (newW !== initialW - dx) return;
const newLeft = initialLeft + dx;
if (newLeft > 0) {
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
}
if (currentResizeDir === 'top') {
const newH = Math.max(400, initialH - dy);
if (newH !== initialH - dy) return;
const newTop = initialTop + dy;
if (newTop > 36) {
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_du_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_du_y', parseInt(win.style.top, 10));
}
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_du_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_du_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_du_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_du_y', parseInt(win.style.top, 10));
}
});
setupDiskUsageDropzone(win);
}
function setDiskUsageViewMode(mode) {
duViewMode = mode;
['mounts', 'split', 'treemap', 'list'].forEach(m => {
const btn = document.getElementById(`du-view-btn-${m}`);
if (btn) {
if (m === mode) btn.classList.add('active');
else btn.classList.remove('active');
}
});
const mountsSec = document.getElementById('du-mounts-section');
const treemapSec = document.getElementById('du-treemap-section');
const listSec = document.getElementById('du-list-section');
const largestSec = document.getElementById('du-largest-section');
const multiBar = document.getElementById('du-multi-bar');
const topBar = document.getElementById('du-top-bar');
if (mountsSec) mountsSec.style.display = (mode === 'mounts') ? 'block' : 'none';
if (treemapSec) treemapSec.style.display = (mode === 'split' || mode === 'treemap') ? 'block' : 'none';
if (listSec) listSec.style.display = (mode === 'split' || mode === 'list') ? 'block' : 'none';
if (largestSec) largestSec.style.display = (mode === 'split' || mode === 'list') ? 'block' : 'none';
if (multiBar) multiBar.style.display = (mode === 'mounts') ? 'none' : 'flex';
if (topBar) topBar.style.display = (mode === 'mounts') ? 'none' : 'block';
if (mode === 'mounts') {
loadSystemDisksOverview();
}
}
function navigateDiskUsageParent() {
if (!duCurrentPath || duCurrentPath === '/') return;
const clean = duCurrentPath.replace(/\/+$/, '');
const lastSlash = clean.lastIndexOf('/');
const parent = lastSlash <= 0 ? '/' : clean.substring(0, lastSlash);
runDiskUsageScan(parent);
}
function navigateDiskUsageActivePane() {
const p = App.panes[App.activePaneIndex]?.path || '/';
runDiskUsageScan(p);
}
function renderDiskUsageBreadcrumbs(path) {
const container = document.getElementById('du-breadcrumbs');
if (!container) return;
const clean = sanitizeCredentials(path);
const parts = clean.split('/').filter(Boolean);
let html = `<button class="du-crumb-btn ${parts.length === 0 ? 'active' : ''}" onclick="runDiskUsageScan('/')" title="Root (/)"><i data-lucide="hard-drive" style="width: 12px; height: 12px;"></i> /</button>`;
let currentAcc = '';
parts.forEach((seg, idx) => {
currentAcc += '/' + seg;
const isLast = idx === parts.length - 1;
const p = currentAcc;
html += `<span class="du-crumb-sep">/</span>`;
html += `<button class="du-crumb-btn ${isLast ? 'active' : ''}" onclick="runDiskUsageScan('${escapeHtml(p)}')" title="${escapeHtml(p)}">${escapeHtml(seg)}</button>`;
});
container.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
function filterDiskUsageItems(val) {
duFilterQuery = (val || '').toLowerCase().trim();
renderDiskUsageData();
}
function jumpToPaneFromDiskUsage(path) {
closeFloatingDiskUsage();
navigatePane(App.activePaneIndex, path);
}
function openTerminalFromDiskUsage(path) {
closeFloatingDiskUsage();
openTerminalInPath(path);
}
async function runDiskUsageScan(path) {
if (!path) return;
if (duAbortController) {
duAbortController.abort();
}
duAbortController = new AbortController();
let targetPath = path;
if (targetPath === '~') {
targetPath = `/home/${App.user?.username || 'bolt'}`;
}
duCurrentPath = targetPath;
const pathIn = document.getElementById('du-path-input');
if (pathIn) pathIn.value = sanitizeCredentials(targetPath);
renderDiskUsageBreadcrumbs(targetPath);
const rescanBtn = document.getElementById('du-rescan-btn');
if (rescanBtn) rescanBtn.classList.add('btn-loading');
const durationBadge = document.getElementById('du-scan-duration-badge');
if (durationBadge) durationBadge.style.display = 'none';
const totalSpaceEl = document.getElementById('du-total-space');
const totalFilesEl = document.getElementById('du-total-files');
const totalDirsEl = document.getElementById('du-total-dirs');
const largestStatEl = document.getElementById('du-largest-stat');
const itemsList = document.getElementById('du-items-list');
const treemapContainer = document.getElementById('du-treemap-container');
const multiBar = document.getElementById('du-multi-bar');
const largestList = document.getElementById('du-largest-files');
if (itemsList) itemsList.innerHTML = '<div style="padding: 24px; text-align: center; color: var(--accent);"><i data-lucide="loader-2" class="spin" style="width: 20px; height: 20px;"></i><div style="margin-top: 6px; font-size: 11.5px;">Scanning directory with multi-threaded parallel engine...</div></div>';
if (treemapContainer) treemapContainer.innerHTML = '<div style="padding: 24px; text-align: center; color: var(--text-dim); width: 100%;">Calculating proportional treemap tiles...</div>';
if (multiBar) multiBar.innerHTML = '';
if (largestList) largestList.innerHTML = '';
if (window.lucide) lucide.createIcons();
const authPath = resolveAuthUri(targetPath);
try {
const resp = await fetch(`/api/tools/disk-usage?path=${encodeURIComponent(authPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` },
signal: duAbortController.signal
});
if (rescanBtn) rescanBtn.classList.remove('btn-loading');
if (!resp.ok) {
const errText = await resp.text();
if (itemsList) itemsList.innerHTML = `<div style="padding: 24px; text-align: center; color: var(--danger);">Scan failed: ${escapeHtml(sanitizeCredentials(errText))}</div>`;
if (treemapContainer) treemapContainer.innerHTML = '';
return;
}
const data = await resp.json();
duCurrentData = data;
if (durationBadge && data.scan_duration_ms !== undefined) {
durationBadge.textContent = `${data.scan_duration_ms}ms`;
durationBadge.style.display = 'inline-flex';
}
if (totalSpaceEl) totalSpaceEl.textContent = data.formatted_total;
if (totalFilesEl) totalFilesEl.textContent = data.total_files.toLocaleString();
if (totalDirsEl) totalDirsEl.textContent = data.total_dirs.toLocaleString();
if (largestStatEl) {
const top = data.items && data.items.length > 0 ? data.items[0] : null;
largestStatEl.textContent = top ? `${top.name} (${top.formatted_size})` : '-';
}
renderDiskUsageData();
} catch (e) {
if (e.name === 'AbortError') return;
if (rescanBtn) rescanBtn.classList.remove('btn-loading');
if (itemsList) itemsList.innerHTML = `<div style="padding: 24px; text-align: center; color: var(--danger);">Error scanning: ${escapeHtml(String(e))}</div>`;
}
}
function renderDiskUsageData() {
if (!duCurrentData) return;
const data = duCurrentData;
const itemsList = document.getElementById('du-items-list');
const treemapContainer = document.getElementById('du-treemap-container');
const multiBar = document.getElementById('du-multi-bar');
const largestList = document.getElementById('du-largest-files');
const countBadge = document.getElementById('du-items-count');
let filteredItems = data.items || [];
if (duFilterQuery) {
filteredItems = filteredItems.filter(it => it.name.toLowerCase().includes(duFilterQuery));
}
if (countBadge) countBadge.textContent = filteredItems.length;
// 1. Render Proportional Multi-Color Stack Bar
if (multiBar) {
const topSlices = (data.items || []).slice(0, 10).filter(it => it.percentage >= 0.5);
multiBar.innerHTML = topSlices.map((it, idx) => {
const color = DU_PALETTE[idx % DU_PALETTE.length];
const pct = it.percentage.toFixed(1);
return `<div class="du-bar-segment" style="width: ${it.percentage}%; background: ${color.bar};" title="${escapeHtml(it.name)}: ${escapeHtml(it.formatted_size)} (${pct}%)" onclick="${it.is_dir ? `runDiskUsageScan('${escapeHtml(it.path)}')` : ''}"></div>`;
}).join('');
}
// 2. Render Interactive Proportional Treemap Tiles
if (treemapContainer) {
if (!data.items || data.items.length === 0) {
treemapContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim); width: 100%;">Directory is empty.</div>';
} else {
const treemapItems = filteredItems.filter(it => it.percentage >= 0.2).slice(0, 24);
if (treemapItems.length === 0) {
treemapContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim); width: 100%;">No items match filter.</div>';
} else {
treemapContainer.innerHTML = treemapItems.map((it, idx) => {
const color = DU_PALETTE[idx % DU_PALETTE.length];
const pct = it.percentage.toFixed(1);
const flexGrow = Math.max(1, Math.min(10, Math.round(it.percentage / 3)));
const flexBasis = Math.max(90, Math.min(320, Math.round(it.percentage * 4.5)));
const icon = it.is_dir ? '📁' : '📄';
return `
<div class="du-treemap-tile" style="flex: ${flexGrow} 1 ${flexBasis}px; background: ${color.bg}; border-color: ${color.border};" onclick="${it.is_dir ? `runDiskUsageScan('${escapeHtml(it.path)}')` : ''}" title="${escapeHtml(it.name)} • ${escapeHtml(it.formatted_size)} (${pct}%) ${it.is_dir ? '\nClick to drill down' : ''}">
<div class="du-treemap-title">
<span>${icon}</span>
<span>${escapeHtml(it.name)}</span>
</div>
<div class="du-treemap-meta">
<span style="color: ${color.text}; font-weight: 700;">${escapeHtml(it.formatted_size)}</span>
<span style="opacity: 0.85;">${pct}%</span>
</div>
</div>
`;
}).join('');
}
}
}
// 3. Render Detailed Item List
if (itemsList) {
if (filteredItems.length === 0) {
itemsList.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-dim);">No items found.</div>';
} else {
itemsList.innerHTML = filteredItems.map((it, idx) => {
const color = DU_PALETTE[idx % DU_PALETTE.length];
const icon = it.is_dir ? '📁' : '📄';
const subInfo = it.is_dir ? `${it.file_count.toLocaleString()} files • ${it.dir_count.toLocaleString()} subdirs` : 'Single file';
const pct = Math.max(1, Math.min(100, Math.round(it.percentage)));
const clickAction = it.is_dir ? `runDiskUsageScan('${escapeHtml(it.path)}')` : '';
return `
<div class="du-item-row" onclick="${clickAction}" title="${it.is_dir ? 'Click to drill down into folder' : ''}">
<div style="font-size: 15px; flex-shrink: 0;">${icon}</div>
<div class="du-item-name-col">
<div class="du-item-name">${escapeHtml(it.name)}</div>
<div class="du-item-sub">${escapeHtml(subInfo)}</div>
</div>
<div class="du-item-bar-wrapper">
<div class="du-item-bar-fill" style="width: ${pct}%; background: ${color.bar};"></div>
</div>
<div class="du-item-size-col">${escapeHtml(it.formatted_size)}</div>
<div class="du-item-pct-col" style="color: ${color.bar};">${it.percentage.toFixed(1)}%</div>
<div class="du-item-actions" onclick="event.stopPropagation()">
<button class="du-action-btn" onclick="jumpToPaneFromDiskUsage('${escapeHtml(it.path)}')" title="Open in Active File Panel"><i data-lucide="folder-input" style="width: 12px; height: 12px;"></i></button>
<button class="du-action-btn" onclick="openTerminalFromDiskUsage('${escapeHtml(it.path)}')" title="Open in Terminal"><i data-lucide="terminal" style="width: 12px; height: 12px;"></i></button>
</div>
</div>
`;
}).join('');
}
}
// 4. Render Top 20 Largest Files
if (largestList) {
if (!data.largest_files || data.largest_files.length === 0) {
largestList.innerHTML = '<div style="padding: 12px; text-align: center; color: var(--text-dim);">No files found.</div>';
} else {
largestList.innerHTML = data.largest_files.map(it => {
const pct = Math.max(1, Math.min(100, Math.round(it.percentage)));
return `
<div class="du-item-row" style="cursor: default;">
<div style="font-size: 15px; flex-shrink: 0;">📄</div>
<div class="du-item-name-col">
<div class="du-item-name">${escapeHtml(it.name)}</div>
<div class="du-item-sub">${escapeHtml(it.path)}</div>
</div>
<div class="du-item-bar-wrapper">
<div class="du-item-bar-fill" style="width: ${pct}%; background: #38bdf8;"></div>
</div>
<div class="du-item-size-col">${escapeHtml(it.formatted_size)}</div>
<div class="du-item-pct-col" style="color: #38bdf8;">${it.percentage.toFixed(1)}%</div>
<div class="du-item-actions">
<button class="du-action-btn" onclick="jumpToPaneFromDiskUsage('${escapeHtml(it.path.substring(0, it.path.lastIndexOf('/')) || '/')}')" title="Reveal in Active Panel"><i data-lucide="folder-input" style="width: 12px; height: 12px;"></i></button>
</div>
</div>
`;
}).join('');
}
}
if (window.lucide) lucide.createIcons();
}
// ---------------- GLOBAL SEARCH ENGINE ----------------
let lastSearchResults = [];
function openSearchModal() {
const activePane = App.panes[App.activePaneIndex];
const rootInput = document.getElementById('search-root-input');
if (rootInput) rootInput.value = sanitizeCredentials(activePane.path);
const feedBtn = document.getElementById('btn-feed-to-pane');
if (feedBtn) feedBtn.style.display = lastSearchResults.length > 0 ? 'inline-flex' : 'none';
showModal('search-modal');
document.getElementById('search-name-pattern')?.focus();
}
async function runGlobalSearch() {
const rawPath = document.getElementById('search-root-input').value.trim();
const path = resolveAuthUri(rawPath);
const name_pattern = document.getElementById('search-name-pattern').value.trim() || null;
const content_query = document.getElementById('search-content-query').value.trim() || null;
const file_type = document.getElementById('search-file-type').value;
const case_sensitive = document.getElementById('search-case-sensitive').checked;
const date_val = document.getElementById('search-date-filter').value;
const modified_days = date_val !== 'all' ? parseInt(date_val, 10) : null;
const tbody = document.getElementById('search-results-tbody');
const stats = document.getElementById('search-results-stats');
const feedBtn = document.getElementById('btn-feed-to-pane');
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--accent); padding: 20px;">Searching directories...</td></tr>';
stats.textContent = 'Searching...';
if (feedBtn) feedBtn.style.display = 'none';
const resp = await fetch('/api/tools/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({
path,
name_pattern,
content_query,
file_type: file_type !== 'any' ? file_type : null,
case_sensitive,
modified_days,
max_results: 300
})
});
if (!resp.ok) {
const errText = sanitizeCredentials(await resp.text());
tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: var(--danger); padding: 20px;">Search failed: ${errText}</td></tr>`;
return;
}
const items = await resp.json();
lastSearchResults = items;
stats.textContent = `${items.length} items found`;
if (feedBtn && items.length > 0) {
feedBtn.style.display = 'inline-flex';
}
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: var(--text-muted); padding: 20px;">No matching files or content found</td></tr>';
return;
}
tbody.innerHTML = items.map(item => `
<tr class="file-row">
<td>
<div style="font-weight: 600; font-size: 12px; color: var(--text-main);">${escapeHtml(sanitizeCredentials(item.name))}</div>
<div style="font-size: 10px; color: var(--text-muted); font-family: var(--font-mono);">${escapeHtml(sanitizeCredentials(item.path))}</div>
${item.matched_lines && item.matched_lines.length > 0 ? `
<div style="margin-top: 4px; padding: 4px 6px; background: #090a0d; border-radius: 3px; font-family: var(--font-mono); font-size: 10px; color: var(--accent);">
${item.matched_lines.map(l => escapeHtml(sanitizeCredentials(l))).join('<br>')}
</div>
` : ''}
</td>
<td class="file-cell-mono">${item.is_dir ? '<DIR>' : formatBytes(item.size)}</td>
<td class="file-cell-mono">${item.modified ? formatDate(item.modified) : '-'}</td>
<td>
<button class="btn btn-icon" onclick="jumpToSearchResult('${escapeHtml(item.path)}', ${item.is_dir})" title="Open / Jump"><i data-lucide="external-link" style="width:12px;"></i></button>
</td>
</tr>
`).join('');
if (window.lucide) lucide.createIcons();
}
function jumpToSearchResult(filePath, isDir) {
closeModal('search-modal');
if (isDir) {
loadPaneDirectory(App.activePaneIndex, filePath);
} else {
const parentDir = filePath.substring(0, filePath.lastIndexOf('/')) || '/';
loadPaneDirectory(App.activePaneIndex, parentDir, true, filePath);
if (isImageExtension(filePath)) {
openImageViewer(filePath);
} else {
openEditorWithFile(filePath);
}
}
}
// ---------------- 🌲 FLAT / BRANCH VIEW (<kbd>Ctrl+B</kbd>) ----------------
function updateBranchToggleState() {
const btn = document.getElementById('btn-toggle-branch');
const activePane = App.panes[App.activePaneIndex];
if (btn) {
if (activePane && activePane.isBranchView) {
btn.classList.add('active');
btn.title = 'Exit Flat Branch View (Ctrl+B)';
} else {
btn.classList.remove('active');
btn.title = 'Toggle Flat Branch View (Ctrl+B)';
}
}
// Synchronize active state on each pane's header branch button
App.panes.forEach((pane, idx) => {
const paneBtn = document.getElementById(`btn-branch-${idx}`);
if (paneBtn) {
if (pane && pane.isBranchView) {
paneBtn.classList.add('active');
paneBtn.title = 'Exit Flat Branch View (Ctrl+B)';
} else {
paneBtn.classList.remove('active');
paneBtn.title = 'Toggle Flat Branch View (Ctrl+B)';
}
}
});
}
function toggleBranchView(paneIndex) {
const pIdx = (paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[pIdx];
if (!pane) return;
if (pane.isVirtual) {
pane.isVirtual = false;
}
pane.isBranchView = !pane.isBranchView;
pane.isBranchTruncated = false;
updateBranchToggleState();
showToast(
pane.isBranchView ? '🌲 Flat Branch View Enabled' : 'Standard Directory View',
'info'
);
loadPaneDirectory(pIdx, pane.path, true, null, true);
}
function exitBranchView(paneIndex, targetPath) {
const pIdx = (paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[pIdx];
if (!pane) return;
if (pane._abortController) {
try {
pane._abortController.abort();
} catch (_) {}
pane._abortController = null;
}
pane.isBranchView = false;
pane.isBranchTruncated = false;
updateBranchToggleState();
const dest = targetPath || pane.path;
loadPaneDirectory(pIdx, dest);
}
function cancelBranchScan(paneIndex) {
const pIdx = (paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[pIdx];
if (!pane) return;
if (pane._abortController) {
try {
pane._abortController.abort();
} catch (_) {}
pane._abortController = null;
}
pane.isBranchView = false;
pane.isBranchTruncated = false;
showToast('Flat Branch View scan canceled', 'info');
loadPaneDirectory(pIdx, pane.path);
}
function renderPaneBranchLoading(paneIndex) {
const tbody = document.getElementById(`pane-tbody-${paneIndex}`);
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="12" style="text-align: center; padding: 40px 16px;">
<div class="pane-branch-loading">
<i data-lucide="loader" class="spin" style="width: 24px; height: 24px; color: var(--accent); margin-bottom: 10px;"></i>
<div style="font-weight: 600; font-size: 13px; color: var(--text-main);">Scanning subdirectories recursively...</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Flat Branch View (Ctrl+B)</div>
<button class="btn btn-xs" style="margin-top: 14px; padding: 3px 10px; border-color: rgba(245, 158, 11, 0.4); color: var(--accent);" onclick="cancelBranchScan(${paneIndex})">
✕ Cancel Scan
</button>
</div>
</td>
</tr>
`;
if (window.lucide) lucide.createIcons();
}
}
function feedSearchResultsToActivePane() {
if (!lastSearchResults || lastSearchResults.length === 0) {
showToast('No search results to feed', 'warning');
return;
}
const pIdx = App.activePaneIndex;
const pane = App.panes[pIdx];
if (!pane) return;
pane.isVirtual = true;
pane.isBranchView = false;
pane.virtualType = 'search';
pane.virtualTitle = `Search Results (${lastSearchResults.length} items)`;
pane.entries = lastSearchResults.map(item => ({
name: item.path.replace(/^\/+/, ''),
path: item.path,
is_dir: item.is_dir,
is_symlink: false,
is_empty: null,
size: item.size,
modified: item.modified,
permissions: item.is_dir ? 'drwxr-xr-x' : '-rw-r--r--',
mode_octal: item.is_dir ? '0755' : '0644',
owner: 'user',
group: 'user',
uid: 1000,
gid: 1000,
mime_type: null,
is_archive: isArchiveFile(item.name),
}));
pane.selected.clear();
pane.cursorIndex = 0;
renderPaneBreadcrumbs(pIdx, 'virtual://search');
renderPaneTable(pIdx);
updatePaneFooter(pIdx, {
total_files: pane.entries.filter(e => !e.is_dir).length,
total_dirs: pane.entries.filter(e => e.is_dir).length,
total_size: pane.entries.reduce((sum, e) => sum + (e.size || 0), 0)
});
closeModal('search-modal');
showToast(`📥 Fed ${lastSearchResults.length} search items into active pane`, 'success');
}
function exitVirtualPane(paneIndex) {
const pIdx = (paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[pIdx];
if (!pane) return;
pane.isVirtual = false;
pane.isBranchView = false;
loadPaneDirectory(pIdx, pane.path);
}
// ---------------- 🏷️ COLOR LABELS & CUSTOM TAGS ----------------
let fileTagsMap = new Map();
let selectedModalColor = 'none';
let modalTargetPaths = [];
async function loadAllFileTags() {
try {
const resp = await fetch('/api/fs/tags/all', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
fileTagsMap.clear();
if (data.tags && Array.isArray(data.tags)) {
data.tags.forEach(t => {
fileTagsMap.set(t.path, t);
});
}
}
} catch (e) {
console.error('Failed to load file tags:', e);
}
}
async function setContextFileColor(color) {
hideContextMenu();
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) return;
const isClearing = (!color || color === 'none' || color === 'clear');
const payload = {
paths,
color_label: isClearing ? 'none' : color,
clear_color: isClearing
};
try {
const resp = await fetch('/api/fs/tags/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify(payload)
});
if (resp.ok) {
paths.forEach(p => {
const existing = fileTagsMap.get(p) || { path: p, tags: [], updated_at: Date.now() };
existing.color_label = isClearing ? null : color;
if (!existing.color_label && (!existing.tags || existing.tags.length === 0) && !existing.custom_icon) {
fileTagsMap.delete(p);
} else {
fileTagsMap.set(p, existing);
}
});
renderAllPanes();
showToast(isClearing ? 'Color label cleared' : `Color label set to ${color}`, 'success');
} else {
showToast('Failed to update color label', 'error');
}
} catch (e) {
console.error('Color label set error:', e);
showToast('Error setting color label', 'error');
}
}
async function setContextCustomIcon(customIcon, targetPaths = null) {
const paths = targetPaths || getSelectedOrCursorPaths();
if (paths.length === 0) return;
const isClearing = (!customIcon || customIcon === 'none' || customIcon === 'clear' || customIcon === 'null');
const payload = {
paths,
custom_icon: isClearing ? 'none' : customIcon.trim(),
clear_custom_icon: isClearing
};
try {
const resp = await fetch('/api/fs/tags/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify(payload)
});
if (resp.ok) {
paths.forEach(p => {
const existing = fileTagsMap.get(p) || { path: p, tags: [], updated_at: Date.now() };
existing.custom_icon = isClearing ? null : customIcon.trim();
if (!existing.color_label && (!existing.tags || existing.tags.length === 0) && !existing.custom_icon) {
fileTagsMap.delete(p);
} else {
fileTagsMap.set(p, existing);
}
});
renderAllPanes();
if (activePropertiesEntry && paths.includes(activePropertiesEntry.path)) {
renderPropTagsTab(activePropertiesEntry.path);
updatePropertiesModalHeaderIcon(activePropertiesEntry);
}
showToast(isClearing ? 'Custom icon cleared' : `Custom icon set to "${customIcon}"`, 'success');
} else {
showToast('Failed to update custom icon', 'error');
}
} catch (e) {
console.error('Custom icon set error:', e);
showToast('Error setting custom icon', 'error');
}
}
function toggleContextColorPalette(e) {
if (e) e.stopPropagation();
const bar = document.getElementById('ctx-color-palette-bar');
if (bar) {
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
}
}
function openPropertiesColorTab() {
hideContextMenu();
triggerProperties();
switchPropertiesTab('prop-tab-tags');
}
let modalTargetTags = [];
function triggerEditTagsModal() {
hideContextMenu();
const paths = getSelectedOrCursorPaths();
if (paths.length === 0) {
showToast('No file selected', 'warning');
return;
}
modalTargetPaths = paths;
const targetLabel = document.getElementById('tags-modal-targets');
if (targetLabel) {
if (paths.length === 1) {
targetLabel.textContent = paths[0].split('/').pop() || paths[0];
} else {
targetLabel.textContent = `${paths.length} items selected`;
}
}
modalTargetTags = [];
if (paths.length === 1 && fileTagsMap.has(paths[0])) {
const info = fileTagsMap.get(paths[0]);
modalTargetTags = [...(info.tags || [])];
}
renderModalTagChips();
const inputEl = document.getElementById('tags-modal-input');
if (inputEl) inputEl.value = '';
showModal('tags-modal');
inputEl?.focus();
}
function renderModalTagChips() {
const container = document.getElementById('tags-modal-chips');
if (!container) return;
container.innerHTML = '';
if (modalTargetTags.length === 0) {
container.innerHTML = '<span style="color: var(--text-muted); font-size: 11px;">No tags yet</span>';
return;
}
modalTargetTags.forEach(t => {
const chip = document.createElement('span');
chip.className = 'file-tag-badge';
chip.style.cssText = 'font-size: 12px; padding: 3px 8px; display: inline-flex; align-items: center; gap: 6px;';
chip.innerHTML = `<span>#${escapeHtml(t)}</span><i data-lucide="x" style="width: 12px; height: 12px; cursor: pointer;" onclick="removeModalTag('${escapeHtml(t)}')"></i>`;
container.appendChild(chip);
});
if (window.lucide) lucide.createIcons({ root: container });
}
function addModalTagFromInput() {
const inputEl = document.getElementById('tags-modal-input');
if (!inputEl) return;
const raw = inputEl.value.trim();
if (!raw) return;
const newTags = raw.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean);
newTags.forEach(t => {
if (!modalTargetTags.includes(t)) {
modalTargetTags.push(t);
}
});
inputEl.value = '';
renderModalTagChips();
}
function removeModalTag(tag) {
modalTargetTags = modalTargetTags.filter(t => t !== tag);
renderModalTagChips();
}
async function saveModalTags() {
if (modalTargetPaths.length === 0) {
closeModal('tags-modal');
return;
}
const inputEl = document.getElementById('tags-modal-input');
if (inputEl && inputEl.value.trim()) {
addModalTagFromInput();
}
try {
const resp = await fetch('/api/fs/tags/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
paths: modalTargetPaths,
set_tags: modalTargetTags
})
});
if (resp.ok) {
modalTargetPaths.forEach(p => {
const existing = fileTagsMap.get(p) || { path: p, color_label: null, tags: [], updated_at: Date.now() };
existing.tags = [...modalTargetTags];
if (!existing.color_label && existing.tags.length === 0) {
fileTagsMap.delete(p);
} else {
fileTagsMap.set(p, existing);
}
});
renderPaneTable(App.activePaneIndex);
closeModal('tags-modal');
showToast('Tags updated', 'success');
} else {
showToast('Failed to save tags', 'error');
}
} catch (e) {
showToast('Error saving tags', 'error');
}
}
// ---------------- CUSTOM SCRIPT ACTIONS RUNNER ----------------
async function runPredefinedAction(command, label) {
const activePane = App.panes[App.activePaneIndex];
const paths = getSelectedOrCursorPaths();
const req = {
command,
working_dir: activePane.path,
selected_files: paths.length > 0 ? paths : [activePane.path],
target_dir: activePane.path
};
document.getElementById('action-output-title').textContent = `⚡ Action: ${label}`;
document.getElementById('action-output-cmd').textContent = `$ ${command}`;
document.getElementById('action-output-status').textContent = 'RUNNING...';
document.getElementById('action-output-status').style.color = 'var(--accent)';
document.getElementById('action-output-duration').textContent = '...';
document.getElementById('action-output-text').textContent = 'Executing command on server...';
showModal('action-output-modal');
const resp = await fetch('/api/actions/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify(req)
});
if (!resp.ok) {
document.getElementById('action-output-status').textContent = 'FAILED';
document.getElementById('action-output-status').style.color = 'var(--danger)';
document.getElementById('action-output-text').textContent = await resp.text();
return;
}
const res = await resp.json();
document.getElementById('action-output-status').textContent = res.success ? `SUCCESS (Code ${res.exit_code || 0})` : `FAILED (Code ${res.exit_code || 1})`;
document.getElementById('action-output-status').style.color = res.success ? 'var(--success)' : 'var(--danger)';
document.getElementById('action-output-duration').textContent = `${res.duration_ms}ms`;
document.getElementById('action-output-cmd').textContent = `$ ${res.executed_command}`;
document.getElementById('action-output-text').textContent = (res.stdout || '') + (res.stderr ? `\n--- STDERR ---\n${res.stderr}` : '') || '(No output produced)';
refreshAllPanes();
}
function copyActionOutput() {
const text = document.getElementById('action-output-text').textContent;
navigator.clipboard.writeText(text).then(() => showToast('Output copied to clipboard!', 'success'));
}
// ---------------- TOUCH & MOBILE GESTURE ENGINE ----------------
let swipeStartX = 0;
let swipeStartY = 0;
let swipeStartTime = 0;
let swipeTriggered = false;
function setupTouchGestures() {
function handleTouchStart(e) {
if (!e.touches || e.touches.length !== 1) return;
if (e.target.closest('.modal, .floating-window, .notedog-window, .calc-window, input, textarea, select, .pane-col-config-btn, .col-resizer, .pane-tree-resizer, .dropdown-menu, .context-menu')) {
swipeStartX = 0;
swipeStartY = 0;
swipeStartTime = 0;
swipeTriggered = true;
return;
}
swipeStartX = e.touches[0].clientX;
swipeStartY = e.touches[0].clientY;
swipeStartTime = Date.now();
swipeTriggered = false;
}
function handleTouchMove(e) {
if (window.innerWidth > 600) return; // Phone only
if (!e.touches || e.touches.length !== 1 || !swipeStartTime || swipeTriggered) return;
const dx = e.touches[0].clientX - swipeStartX;
const dy = e.touches[0].clientY - swipeStartY;
// Trigger immediate pane slide as soon as finger moves > 38px horizontally
if (Math.abs(dx) > 38 && Math.abs(dx) > Math.abs(dy) * 1.15) {
const visible = getVisiblePaneCount();
if (visible <= 1) return;
swipeTriggered = true;
if (dx < 0) {
// Slide Left -> Next Pane
const nextPane = (App.activePaneIndex + 1) % visible;
setActivePane(nextPane);
triggerHaptic(25);
} else {
// Slide Right -> Prev Pane
const prevPane = (App.activePaneIndex - 1 + visible) % visible;
setActivePane(prevPane);
triggerHaptic(25);
}
}
}
function handleTouchEnd(e) {
if (window.innerWidth > 600) return;
if (!swipeTriggered && swipeStartTime && e.changedTouches && e.changedTouches.length > 0) {
if (Date.now() - swipeStartTime <= 800) {
const dx = e.changedTouches[0].clientX - swipeStartX;
const dy = e.changedTouches[0].clientY - swipeStartY;
if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy) * 1.1) {
const visible = getVisiblePaneCount();
if (visible > 1) {
swipeTriggered = true;
if (dx < 0) {
const nextPane = (App.activePaneIndex + 1) % visible;
setActivePane(nextPane);
triggerHaptic(25);
} else {
const prevPane = (App.activePaneIndex - 1 + visible) % visible;
setActivePane(prevPane);
triggerHaptic(25);
}
}
}
}
}
swipeStartX = 0;
swipeStartY = 0;
swipeStartTime = 0;
swipeTriggered = false;
}
window.addEventListener('touchstart', handleTouchStart, { passive: true });
window.addEventListener('touchmove', handleTouchMove, { passive: true });
window.addEventListener('touchend', handleTouchEnd, { passive: true });
window.addEventListener('touchcancel', handleTouchEnd, { passive: true });
}
// ---------------- SYNCTHING INTEGRATION ----------------
function openSyncthingModal() {
showModal('syncthing-modal');
loadSyncthingDashboard();
}
async function loadSyncthingDashboard() {
const badge = document.getElementById('syncthing-conn-badge');
const tbodyFolders = document.getElementById('syncthing-folders-tbody');
const tbodyPeers = document.getElementById('syncthing-peers-tbody');
if (badge) {
badge.style.background = 'rgba(245, 158, 11, 0.15)';
badge.style.color = 'var(--accent)';
badge.textContent = 'Connecting...';
}
try {
const resp = await fetch('/api/tools/syncthing/status', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (badge) {
badge.style.background = 'rgba(239, 68, 68, 0.15)';
badge.style.color = 'var(--danger)';
badge.textContent = 'Offline / Error';
}
if (tbodyFolders) tbodyFolders.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--danger); padding:16px;">Failed to reach Syncthing API: ${await resp.text()}</td></tr>`;
if (tbodyPeers) tbodyPeers.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:12px;">No peers available</td></tr>`;
return;
}
const data = await resp.json();
if (badge) {
if (data.available) {
badge.style.background = 'rgba(16, 185, 129, 0.15)';
badge.style.color = '#34d399';
badge.textContent = `🟢 Online (${data.peers.filter(p => p.connected).length} Connected)`;
} else {
badge.style.background = 'rgba(239, 68, 68, 0.15)';
badge.style.color = 'var(--danger)';
badge.textContent = '⚪ Disconnected / Not Running';
}
}
const downEl = document.getElementById('st-down-speed');
const upEl = document.getElementById('st-up-speed');
const fCountEl = document.getElementById('st-folders-count');
const pCountEl = document.getElementById('st-peers-count');
if (downEl) downEl.textContent = `${formatBytes(data.total_download_speed)}/s`;
if (upEl) upEl.textContent = `${formatBytes(data.total_upload_speed)}/s`;
if (fCountEl) fCountEl.textContent = data.folders.length;
if (pCountEl) pCountEl.textContent = data.peers.filter(p => p.connected).length;
const bannerText = document.getElementById('st-daemon-url-text');
const guiLink = document.getElementById('st-daemon-gui-link');
if (bannerText) bannerText.textContent = `🌐 Syncthing Daemon: ${data.url} ${data.my_id ? '(' + data.my_id.substring(0, 7) + '...)' : ''}`;
if (guiLink) guiLink.href = data.url;
// Render Folders
if (tbodyFolders) {
if (data.folders.length === 0) {
tbodyFolders.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:16px;">${data.available ? 'No synchronized folders configured yet' : data.message}</td></tr>`;
} else {
tbodyFolders.innerHTML = data.folders.map(f => {
const isUpToDate = f.in_sync_percent >= 99.9;
const statusColor = f.paused ? 'var(--text-muted)' : (isUpToDate ? 'var(--success)' : 'var(--accent)');
return `
<tr class="file-row">
<td><b>${escapeHtml(f.label)}</b> <span style="font-size:10px; color:var(--text-muted);">(${escapeHtml(f.id)})</span></td>
<td><code style="cursor:pointer; color:var(--accent);" onclick="jumpToFolderInPane('${escapeHtml(f.path)}')" title="Click to open in active pane">${escapeHtml(f.path)}</code></td>
<td><span style="color:${statusColor}; font-weight:600;">${f.state.toUpperCase()}</span></td>
<td>
<div style="display:flex; align-items:center; gap:6px;">
<div style="flex:1; background:var(--bg-dark); height:6px; border-radius:3px; overflow:hidden; border:1px solid var(--border);">
<div style="background:${statusColor}; height:100%; width:${f.in_sync_percent}%;"></div>
</div>
<span style="font-size:10px; font-family:var(--font-mono);">${f.in_sync_percent.toFixed(0)}%</span>
</div>
</td>
<td style="text-align:right;">
<button class="btn btn-sm" onclick="triggerSyncthingScan('${f.id}')" title="Scan Folder"><i data-lucide="rotate-cw"></i></button>
<button class="btn btn-sm" onclick="jumpToFolderInPane('${escapeHtml(f.path)}')" title="Open in Active Pane"><i data-lucide="folder-open"></i></button>
</td>
</tr>
`;
}).join('');
}
}
// Render Peers
if (tbodyPeers) {
if (data.peers.length === 0) {
tbodyPeers.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:12px;">No remote devices paired</td></tr>`;
} else {
tbodyPeers.innerHTML = data.peers.map(p => `
<tr class="file-row">
<td><b>${escapeHtml(p.name)}</b> <span style="font-size:10px; color:var(--text-muted);">(${p.id.substring(0, 7)}...)</span></td>
<td><span style="font-family:var(--font-mono); font-size:11px;">${escapeHtml(p.address || 'Dynamic')}</span></td>
<td><span style="color:${p.connected ? 'var(--success)' : 'var(--text-muted)'}; font-weight:600;">${p.connected ? '● CONNECTED' : '○ DISCONNECTED'}</span></td>
<td style="font-family:var(--font-mono); color:var(--success);">${formatBytes(p.in_bps)}/s</td>
<td style="font-family:var(--font-mono); color:var(--accent);">${formatBytes(p.out_bps)}/s</td>
</tr>
`).join('');
}
}
if (window.lucide) lucide.createIcons();
} catch (e) {
if (badge) {
badge.style.background = 'rgba(239, 68, 68, 0.15)';
badge.style.color = 'var(--danger)';
badge.textContent = 'Error';
}
if (tbodyFolders) tbodyFolders.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--danger); padding:16px;">Error connecting to Syncthing: ${e}</td></tr>`;
}
}
async function triggerSyncthingScan(folderId) {
try {
const resp = await fetch('/api/tools/syncthing/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${App.token}` },
body: JSON.stringify({ folder_id: folderId || null })
});
if (resp.ok) {
showToast('Scan initiated', 'success');
loadSyncthingDashboard();
} else {
showToast(`Scan failed: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Error: ${e}`, 'error');
}
}
function jumpToFolderInPane(path) {
closeModal('syncthing-modal');
loadPaneDirectory(App.activePaneIndex, path);
}
async function testAndSaveSyncthingConfig() {
const status = document.getElementById('syncthing-settings-status');
if (status) {
status.style.display = 'block';
status.style.color = 'var(--accent)';
status.textContent = 'Testing connection...';
}
try {
const resp = await fetch('/api/tools/syncthing/status', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const st = await resp.json();
if (status) {
status.style.color = st.available ? 'var(--success)' : 'var(--warning)';
status.textContent = st.available ? `✓ Connected to Syncthing (${st.folders.length} folders, ${st.peers.length} peers)` : `⚠️ ${st.message}`;
}
} else {
if (status) {
status.style.color = 'var(--danger)';
status.textContent = `✗ ${await resp.text()}`;
}
}
} catch (e) {
if (status) {
status.style.color = 'var(--danger)';
status.textContent = `✗ Error: ${e}`;
}
}
}
function toggleParanoidMode() {
App.paranoidMode = !App.paranoidMode;
updateParanoidBadge();
showToast(`Paranoid Mode: ${App.paranoidMode ? 'ON' : 'OFF'}`, 'info');
}
function refreshAllPanes() {
for (let i = 0; i < getVisiblePaneCount(); i++) refreshPane(i);
}
function handleLogoutOrExit() {
const isStandalone = App.config?.server?.standalone || window.__TAURI__ !== undefined;
if (isStandalone) {
confirmExitBrum();
} else {
logout();
}
}
async function confirmExitBrum() {
const isStandalone = App.config?.server?.standalone || window.__TAURI__ !== undefined || window.__WRY__ !== undefined || document.body.classList.contains('standalone-mode');
const confirmed = await showConfirmDialog({
title: 'Quit Brum',
subtitle: isStandalone ? 'Terminate native application' : 'Clean session shutdown',
message: 'Are you sure you want to quit Brum and terminate the process?',
icon: 'power',
type: 'danger',
confirmText: 'Quit Application',
cancelText: 'Cancel'
});
if (!confirmed) return;
try {
showToast("Exiting Brum...", "info");
await fetch('/api/system/exit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('cd_token') || ''}`
}
});
} catch (e) {}
if (window.__TAURI__?.process?.exit) {
try {
window.__TAURI__.process.exit(0);
} catch (e) {}
} else {
try {
window.close();
} catch (_) {}
document.body.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background: var(--bg-dark, #0b0f14); color: var(--text-main, #e1e7ec); font-family: sans-serif; text-align: center; padding: 20px;">
<h2 style="color: var(--accent, #f59e0b); margin-bottom: 8px;">⚡ Brum Closed</h2>
<p style="color: var(--text-muted, #7a889b); font-size: 13px;">The application process has been terminated cleanly. You can safely close this window.</p>
</div>
`;
}
}
function logout() {
localStorage.removeItem('cd_token');
localStorage.removeItem('cd_is_locked');
App.token = '';
App.user = null;
App.isLocked = false;
document.documentElement.classList.remove('auth-ready', 'auth-pending-lock', 'auth-verifying');
document.documentElement.classList.add('auth-pending-login');
location.reload();
}
function updateMobileBottomBar() {
const pane = App.panes[App.activePaneIndex];
const count = pane ? pane.selected.size : 0;
const copyBtn = document.getElementById('mob-btn-copy');
const moveBtn = document.getElementById('mob-btn-move');
const delBtn = document.getElementById('mob-btn-delete');
const actLabel = document.getElementById('mob-actions-label');
const actBtn = document.getElementById('mob-btn-actions');
if (copyBtn) {
const span = copyBtn.querySelector('span');
if (span) span.textContent = count > 0 ? `Copy (${count})` : 'Copy';
if (count > 0) copyBtn.classList.add('has-selection');
else copyBtn.classList.remove('has-selection');
}
if (moveBtn) {
const span = moveBtn.querySelector('span');
if (span) span.textContent = count > 0 ? `Move (${count})` : 'Move';
if (count > 0) moveBtn.classList.add('has-selection');
else moveBtn.classList.remove('has-selection');
}
if (delBtn) {
const span = delBtn.querySelector('span');
if (span) span.textContent = count > 0 ? `Delete (${count})` : 'Delete';
if (count > 0) delBtn.classList.add('has-selection');
else delBtn.classList.remove('has-selection');
}
if (actLabel) {
actLabel.textContent = count > 0 ? `Actions (${count})` : 'Actions';
}
if (actBtn) {
if (count > 0) actBtn.classList.add('has-selection');
else actBtn.classList.remove('has-selection');
}
}
function toggleRowSelect(paneIndex, path, idx) {
const pane = App.panes[paneIndex];
if (pane.selected.has(path)) {
pane.selected.delete(path);
} else {
pane.selected.add(path);
}
pane.cursorIndex = idx;
renderPaneTable(paneIndex);
updateMobileBottomBar();
}
function toggleSelectAll(paneIndex) {
const pane = App.panes[paneIndex];
if (pane.selected.size === pane.entries.length) {
pane.selected.clear();
} else {
pane.entries.forEach(e => pane.selected.add(e.path));
}
renderPaneTable(paneIndex);
updateMobileBottomBar();
}
function clearActiveSelections() {
const pane = App.panes[App.activePaneIndex];
if (pane) {
pane.selected.clear();
renderPaneTable(App.activePaneIndex);
}
updateMobileBottomBar();
}
// ---------------- DYNAMIC VIEWPORT HEIGHT & BOTTOM ADDRESS BAR SYNC ----------------
function updateDynamicViewportHeight() {
if (window.visualViewport) {
const vh = Math.round(window.visualViewport.height);
const vTop = Math.round(window.visualViewport.offsetTop || 0);
document.documentElement.style.setProperty('--viewport-height', `${vh}px`);
document.body.style.height = `${vh}px`;
// Dynamic bottom offset if browser address bar / chrome sits at bottom (e.g. Vivaldi / Chrome for Android)
const bottomBarOffset = Math.max(0, Math.round(window.innerHeight - (vh + vTop)));
document.documentElement.style.setProperty('--mobile-bottom-offset', `${bottomBarOffset}px`);
} else {
const vh = window.innerHeight;
document.documentElement.style.setProperty('--viewport-height', `${vh}px`);
document.documentElement.style.setProperty('--mobile-bottom-offset', '0px');
}
}
let _lastViewportType = getViewportType();
let _lastVisiblePaneCount = getVisiblePaneCount();
let _responsiveDebounceTimer = null;
function checkResponsiveViewportLayout(force = false) {
const currentVp = getViewportType();
const targetLayout = getLayoutForViewport(currentVp);
const targetCount = getVisiblePaneCount();
const container = document.getElementById('panes-grid');
const renderedPanes = container ? container.querySelectorAll(':scope > .pane') : [];
const renderedCount = renderedPanes.length;
let needsReRender = force === true;
// 1. Viewport category transition (phone <-> tablet <-> pc)
if (_lastViewportType !== currentVp) {
needsReRender = true;
if (App.layout !== targetLayout) {
App.layout = targetLayout;
updateActiveLayoutUI(targetLayout);
}
}
// 2. Tablet / Phone layout enforcement: ensure App.layout matches target layout
if ((currentVp === 'phone' || currentVp === 'tablet') && App.layout !== targetLayout) {
App.layout = targetLayout;
updateActiveLayoutUI(targetLayout);
needsReRender = true;
}
// 3. Rendered pane count mismatch in DOM
if (renderedCount > 0 && renderedCount !== targetCount) {
needsReRender = true;
}
// 4. Container class mismatch
if (container && !container.classList.contains(App.layout)) {
needsReRender = true;
}
_lastViewportType = currentVp;
_lastVisiblePaneCount = targetCount;
if (needsReRender && container) {
if (App.isLocked || localStorage.getItem('cd_is_locked') === 'true' || (!App.token && !App.isStandalone && App.systemStatus?.auth_enabled !== false)) {
return;
}
const visibleCount = getVisiblePaneCount();
if (App.activePaneIndex >= visibleCount) {
App.activePaneIndex = Math.max(0, visibleCount - 1);
}
renderAllPanes();
applyAllColumnWidths();
}
}
function triggerResponsiveViewportUpdate() {
updateDynamicViewportHeight();
checkResponsiveViewportLayout();
if (typeof updateAllPaneTabsVisibility === 'function') updateAllPaneTabsVisibility();
if (_responsiveDebounceTimer) clearTimeout(_responsiveDebounceTimer);
_responsiveDebounceTimer = setTimeout(() => {
updateDynamicViewportHeight();
checkResponsiveViewportLayout();
if (typeof updateAllPaneTabsVisibility === 'function') updateAllPaneTabsVisibility();
}, 120);
}
window.addEventListener('resize', triggerResponsiveViewportUpdate);
window.addEventListener('orientationchange', () => {
triggerResponsiveViewportUpdate();
setTimeout(triggerResponsiveViewportUpdate, 50);
setTimeout(triggerResponsiveViewportUpdate, 250);
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', triggerResponsiveViewportUpdate);
window.visualViewport.addEventListener('scroll', updateDynamicViewportHeight);
}
document.addEventListener('DOMContentLoaded', () => {
updateDynamicViewportHeight();
checkResponsiveViewportLayout(false);
});
window.addEventListener('load', () => {
updateDynamicViewportHeight();
checkResponsiveViewportLayout(false);
});
// ---------------- GLOBAL SPOTLIGHT QUICK-SWITCHER ----------------
let spotlightCurrentCat = 'all';
let spotlightQuery = '';
let spotlightSelectedIndex = 0;
let spotlightItems = [];
let spotlightAsyncDebounceTimer = null;
let spotlightAsyncAbortCtrl = null;
let spotlightAsyncLoading = false;
const SPOTLIGHT_STATIC_ACTIONS = [
{ id: 'upload-folder', title: 'Upload Folder from Device', sub: 'Upload an entire local directory recursively into the active pane via browser (webkitdirectory)', icon: 'folder-up', cat: 'actions', action: () => triggerDeviceFolderUpload(App.activePaneIndex) },
{ id: 'renamer', title: 'Batch Renamer', sub: 'Multi-file pattern replacement, sequential renamer & live diff preview (Ctrl+M)', icon: 'file-signature', cat: 'actions', action: () => openBatchRenamer() },
{ id: 'hexeditor', title: 'Hex Editor', sub: 'Binary hexadecimal viewer, byte patching & checksum calculator', icon: 'binary', cat: 'actions', action: () => openHexEditor() },
{ id: 'splitter', title: 'File Splitter & Combiner', sub: 'Split large files into chunks (.001, .002) and verify/combine with SHA-256', icon: 'scissors', cat: 'actions', action: () => openFileSplitterModal() },
{ id: 'notedog', title: 'Notes', sub: 'Hierarchical notes, markdown editor, interactive checklists, templates & versions', icon: 'assets/note.webp', cat: 'actions', action: () => openFloatingNoteDog() },
{ id: 'calc', title: 'Calculator', sub: 'Interactive floating calculator with storage units & base conversions', icon: 'assets/calc.webp', cat: 'actions', action: () => openFloatingCalculator() },
{ id: 'branch', title: 'Flat', sub: 'Flatten all subdirectories into a single unified list (Ctrl+B)', icon: 'assets/amber-git-branch.webp', cat: 'actions', action: () => toggleBranchView() },
{ id: 'tree', title: 'Tree', sub: 'Collapsible directory navigation tree (Ctrl+T)', icon: 'assets/amber-folder-tree.webp', cat: 'actions', action: () => toggleFolderTree() },
{ id: 'tags', title: 'Color Labels & Custom Tags', sub: 'Assign color labels and custom tags to selected items', icon: 'tag', cat: 'actions', action: () => triggerEditTagsModal() },
{ id: 'term', title: 'Terminal', sub: 'Open integrated interactive terminal (` or F4)', icon: 'assets/term.webp', cat: 'actions', action: () => toggleTerminal() },
{ id: 'edit', title: 'Editor', sub: 'Open floating Edit code & text editor (F4)', icon: 'assets/edit.webp', cat: 'actions', action: () => openFloatingEditor() },
{ id: 'diff', title: 'Compare', sub: 'Compare files or directories side-by-side (F9)', icon: 'assets/diff.webp', cat: 'actions', action: () => triggerDiff() },
{ id: 'search', title: 'Search', sub: 'Search files and folders recursively (Ctrl+F)', icon: 'assets/search.webp', cat: 'actions', action: () => openSearchModal() },
{ id: 'fleet', title: 'Fleet Control', sub: 'Multi-host node switcher, remote cluster manager & node diagnostics', icon: 'network', cat: 'actions', action: () => openFleetManagerModal() },
{ id: 'shares', title: 'Share Manager', sub: 'Manage public share links and guest upload dropboxes', icon: 'assets/sharemgr.webp', cat: 'actions', action: () => openSharesManager() },
{ id: 'sync', title: 'Backup & Sync', sub: 'Delta Backup & Sync Studio: Two-Way Sync, Mirror, Additive Contribute & Snapshot Versioning', icon: 'assets/sync.webp', cat: 'actions', action: () => openSyncModal() },
{ id: 'du', title: 'Disk Usage', sub: 'Disk Usage & Storage Treemap Analyzer: inspect space consumption', icon: 'assets/amber-piechart.webp', cat: 'actions', action: () => openDiskUsageModal() },
{ id: 'syncthing', title: 'Syncthing', sub: 'Continuous peer-to-peer file synchronization dashboard', icon: 'assets/syncthing.webp', cat: 'actions', action: () => openSyncthingModal() },
{ id: 'convert', title: 'Format Converter', sub: 'Universal transcoder: batch convert images, documents, audio, videos', icon: 'assets/convertx.webp', cat: 'actions', action: () => openConverterModal() },
{ id: 'pdf', title: 'PDF Studio', sub: 'PDF Studio: visual merge, split, extract pages & inspect PDFs', icon: 'assets/amber-pdftool.webp', cat: 'actions', action: () => openPdfToolModal() },
{ id: 'cad', title: '3D CAD Studio', sub: '3D CAD Studio: interactive 3D model, mesh & CAD drawing viewer (STL, OBJ, GLTF, 3MF, STEP, DXF)', icon: 'box', cat: 'actions', action: () => openCadStudio() },
{ id: 'duplicates', title: 'Duplicate Finder', sub: 'Multi-stage size, partial & SHA-256 duplicate scanner and safe quarantine cleaner', icon: 'copy-check', cat: 'actions', action: () => openDuplicateFinder() },
{ id: 'tageditor', title: 'Tag Editor', sub: 'Audio ID3/FLAC metadata & artwork editor, batch sequential auto-numberer & EXIF inspector', icon: 'tag', cat: 'actions', action: () => openTagEditor() },
{ id: 'logviewer', title: 'Log Viewer', sub: 'Real-time log tailing, regex & inverted filter, log level parsing & autoscroll', icon: 'scroll-text', cat: 'actions', action: () => openLogViewer() },
{ id: 'mediaplayer', title: 'Media Player', sub: 'Universal video & media player, subtitles, PiP popout & playlist', icon: 'assets/media.webp', cat: 'actions', action: () => openMediaPlayer() },
{ id: 'sounddog', title: 'Audio Player', sub: 'Audio player, jukebox, playlists & 10-band studio equalizer', icon: 'assets/amber-media.webp', cat: 'actions', action: () => openSoundDog() },
{ id: 'tetradog', title: 'Tetris', sub: 'Classic arcade block puzzle with synchronized top scores & leaderboards', icon: 'assets/amber-tetris.webp', cat: 'actions', action: () => openTetraDog() },
{ id: 'tasks', title: 'Task Manager', sub: 'View active background transfers, speeds, and queued jobs', icon: 'assets/task.webp', cat: 'actions', action: () => openFloatingTaskManager() },
{ id: 'settings', title: 'User Settings & Preferences', sub: 'Themes, keybindings, and preferences (F10)', icon: 'assets/amber-frameless-settings.webp', cat: 'actions', action: () => openSettingsModal() },
{ id: 'admin', title: 'Admin Control Panel', sub: 'User management, RBAC, mounts, audit logs', icon: 'assets/amber-frameless-admin.webp', cat: 'actions', action: () => openAdminPanel() },
{ id: 'about', title: 'About Brum', sub: 'Version, architecture, developer info, and MIT license details', icon: 'assets/amber-frameless-info.webp', cat: 'actions', action: () => openAboutModal() },
{ id: 'profile', title: 'User Profile & Password', sub: 'Account credentials, session avatar, and security', icon: 'user', cat: 'actions', action: () => openUserProfileModal() },
{ id: 'lock', title: 'Lock Session', sub: 'Lock Brum immediately (Ctrl+Alt+L)', icon: 'lock', cat: 'actions', action: () => lockSession() },
{ id: 'mkdir', title: 'New Folder', sub: 'Create a new directory in active pane (F7)', icon: 'folder-plus', cat: 'actions', action: () => triggerMkdir() },
{ id: 'newfile', title: 'New Text File', sub: 'Create a new empty text document', icon: 'file-plus', cat: 'actions', action: () => triggerNewFile() },
{ id: 'new-template', title: 'New File from Template...', sub: 'Create a new file using Markdown, HTML, Shell, Python or custom templates', icon: 'file-code-2', cat: 'actions', action: () => triggerCreateFromTemplate('markdown') },
{ id: 'manage-templates', title: 'Manage File Templates', sub: 'Configure boilerplate templates and dynamic variables', icon: 'file-code-2', cat: 'actions', action: () => { openSettings(); switchSettingsTab('tab-templates'); } },
{ id: 'vault-create', title: 'Create Encrypted Vault', sub: 'Create a password-protected AES-256-GCM encrypted vault (.cdvault)', icon: 'shield-check', cat: 'actions', action: () => openCreateVaultModal() },
{ id: 'compress', title: 'Compress to Archive', sub: 'Create .zip or .tar.gz from selected files', icon: 'archive', cat: 'actions', action: () => triggerCompressModal() },
{ id: 'perms', title: 'Permissions & Ownership', sub: 'Visual Unix chmod & chown editor', icon: 'shield', cat: 'actions', action: () => triggerPermissions() },
{ id: 'theme-charcoal', title: 'Theme: Woofsons Amber Charcoal', sub: 'Switch theme to Woofsons Amber Charcoal (Dark)', icon: 'palette', cat: 'actions', action: () => applyTheme('amber-charcoal') },
{ id: 'theme-zink', title: 'Theme: Woofsons Amber Zink', sub: 'Switch theme to Woofsons Amber Zink (Light)', icon: 'palette', cat: 'actions', action: () => applyTheme('zink') },
{ id: 'theme-amber', title: 'Theme: Amber (Classic Woofson)', sub: 'Switch theme to Amber Gold', icon: 'palette', cat: 'actions', action: () => applyTheme('amber') },
{ id: 'theme-emerald', title: 'Theme: Emerald', sub: 'Switch theme to Emerald Green', icon: 'palette', cat: 'actions', action: () => applyTheme('emerald') },
{ id: 'theme-sky', title: 'Theme: Sky Blue', sub: 'Switch theme to Sky Blue', icon: 'palette', cat: 'actions', action: () => applyTheme('sky') },
{ id: 'theme-nord', title: 'Theme: Nord Frost', sub: 'Switch theme to Arctic Nord', icon: 'palette', cat: 'actions', action: () => applyTheme('nord') },
{ id: 'theme-cyberpunk', title: 'Theme: Cyberpunk Neon', sub: 'Switch theme to Neon Cyberpunk', icon: 'palette', cat: 'actions', action: () => applyTheme('cyberpunk') },
{ id: 'theme-dracula', title: 'Theme: Dracula', sub: 'Switch theme to Gothic Dracula Dark', icon: 'palette', cat: 'actions', action: () => applyTheme('dracula') },
{ id: 'theme-monokai', title: 'Theme: Monokai Pro', sub: 'Switch theme to Monokai High Contrast', icon: 'palette', cat: 'actions', action: () => applyTheme('monokai') },
{ id: 'theme-matrix', title: 'Theme: Matrix Terminal', sub: 'Switch theme to Phosphor Green Matrix', icon: 'palette', cat: 'actions', action: () => applyTheme('matrix') },
{ id: 'theme-skumring', title: 'Theme: Larvikite Skumring', sub: 'Switch theme to Larvikite Skumring (Dark Cyan)', icon: 'palette', cat: 'actions', action: () => applyTheme('skumring') },
{ id: 'theme-demring', title: 'Theme: Larvikite Demring', sub: 'Switch theme to Larvikite Demring (Light Cyan)', icon: 'palette', cat: 'actions', action: () => applyTheme('demring') },
{ id: 'theme-trollnatt', title: 'Theme: Larvikite Trollnatt', sub: 'Switch theme to Larvikite Trollnatt (Dark Forest)', icon: 'palette', cat: 'actions', action: () => applyTheme('trollnatt') },
{ id: 'theme-myrtaake', title: 'Theme: Larvikite Myrtåke', sub: 'Switch theme to Larvikite Myrtåke (Light Forest)', icon: 'palette', cat: 'actions', action: () => applyTheme('myrtaake') },
{ id: 'theme-bergtatt', title: 'Theme: Kittelsen Bergtatt', sub: 'Switch theme to Kittelsen Bergtatt (Dark Amber/Gold)', icon: 'palette', cat: 'actions', action: () => applyTheme('bergtatt') },
{ id: 'theme-soria-moria', title: 'Theme: Kittelsen Soria Moria', sub: 'Switch theme to Kittelsen Soria Moria (Parchment Gold)', icon: 'palette', cat: 'actions', action: () => applyTheme('soria-moria') },
{ id: 'theme-pestanatt', title: 'Theme: Kittelsen Pestanatt', sub: 'Switch theme to Kittelsen Pestanatt (Dark Crimson)', icon: 'palette', cat: 'actions', action: () => applyTheme('pestanatt') },
{ id: 'theme-sotslette', title: 'Theme: Kittelsen Sotslette', sub: 'Switch theme to Kittelsen Sotslette (Parchment Crimson)', icon: 'palette', cat: 'actions', action: () => applyTheme('sotslette') }
];
function openSpotlightModal() {
const modal = document.getElementById('spotlight-modal');
if (!modal) return;
spotlightQuery = '';
spotlightCurrentCat = 'all';
spotlightSelectedIndex = 0;
spotlightAsyncLoading = false;
const input = document.getElementById('spotlight-input');
if (input) input.value = '';
const clearBtn = document.getElementById('spotlight-clear-btn');
if (clearBtn) clearBtn.style.display = 'none';
updateSpotlightCatButtons();
buildSpotlightItems();
renderSpotlightResults();
modal.style.display = 'flex';
setTimeout(() => input?.focus(), 50);
}
function closeSpotlightModal() {
const modal = document.getElementById('spotlight-modal');
if (modal) modal.style.display = 'none';
if (spotlightAsyncAbortCtrl) {
try { spotlightAsyncAbortCtrl.abort(); } catch (_) {}
spotlightAsyncAbortCtrl = null;
}
}
function toggleSpotlightModal() {
const modal = document.getElementById('spotlight-modal');
if (modal && modal.style.display === 'flex') {
closeSpotlightModal();
} else {
openSpotlightModal();
}
}
function clearSpotlightInput() {
const input = document.getElementById('spotlight-input');
if (input) {
input.value = '';
handleSpotlightInput('');
input.focus();
}
}
function filterSpotlightCategory(cat) {
spotlightCurrentCat = cat;
updateSpotlightCatButtons();
spotlightSelectedIndex = 0;
buildSpotlightItems();
renderSpotlightResults();
triggerSpotlightAsyncSearch();
}
function updateSpotlightCatButtons() {
const cats = ['all', 'actions', 'paths', 'files', 'grep', 'bookmarks', 'recent'];
cats.forEach(c => {
const btn = document.getElementById(`spotlight-cat-${c}`);
if (btn) {
if (c === spotlightCurrentCat) btn.classList.add('active');
else btn.classList.remove('active');
}
});
}
function handleSpotlightInput(val) {
const raw = val || '';
let query = raw.trim();
// Prefix routing
if (query.startsWith('@')) {
spotlightCurrentCat = 'actions';
query = query.slice(1).trim();
} else if (query.startsWith('grep:')) {
spotlightCurrentCat = 'grep';
query = query.slice(5).trim();
} else if (query.startsWith('/') && !query.startsWith('/home') && !query.startsWith('/etc') && !query.startsWith('/var') && !query.startsWith('/tmp') && !query.startsWith('/usr') && query.length > 1 && !query.includes('/')) {
// Single leading slash like "/search term" triggers grep
spotlightCurrentCat = 'grep';
query = query.slice(1).trim();
} else if (query.startsWith('re:') || query.startsWith('>')) {
spotlightCurrentCat = 'files';
}
spotlightQuery = query;
updateSpotlightCatButtons();
const clearBtn = document.getElementById('spotlight-clear-btn');
if (clearBtn) clearBtn.style.display = raw ? 'inline-flex' : 'none';
spotlightSelectedIndex = 0;
buildSpotlightItems();
renderSpotlightResults();
triggerSpotlightAsyncSearch();
}
function triggerSpotlightAsyncSearch() {
if (spotlightAsyncDebounceTimer) {
clearTimeout(spotlightAsyncDebounceTimer);
}
if (spotlightAsyncAbortCtrl) {
try { spotlightAsyncAbortCtrl.abort(); } catch (_) {}
spotlightAsyncAbortCtrl = null;
}
const q = spotlightQuery;
const cat = spotlightCurrentCat;
if (!q || q.length < 2 || (cat !== 'all' && cat !== 'files' && cat !== 'grep')) {
return;
}
spotlightAsyncDebounceTimer = setTimeout(async () => {
try {
spotlightAsyncAbortCtrl = new AbortController();
spotlightAsyncLoading = true;
const activePath = App.panes[App.activePaneIndex]?.path || `/home/${App.user?.username || 'bolt'}`;
const isGrep = cat === 'grep' || q.startsWith('grep:') || (q.startsWith('/') && !q.includes('/'));
const cleanQ = isGrep ? q.replace(/^(grep:|\/)/, '').trim() : q;
const isRegex = cleanQ.startsWith('re:') || cleanQ.startsWith('>');
const searchPat = isRegex ? cleanQ.replace(/^(re:|>)/, '').trim() : cleanQ;
const payload = {
path: activePath,
content_query: isGrep ? cleanQ : undefined,
name_pattern: !isGrep ? searchPat : undefined,
is_regex: isRegex,
max_results: 40
};
const res = await fetch('/api/tools/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify(payload),
signal: spotlightAsyncAbortCtrl.signal
});
if (res.ok) {
const results = await res.json();
const asyncItems = [];
results.forEach(r => {
if (isGrep && r.matched_lines && r.matched_lines.length > 0) {
r.matched_lines.forEach(ml => {
const lineMatch = ml.match(/^L(\d+):\s*(.*)$/);
const lineNum = lineMatch ? lineMatch[1] : null;
const lineText = lineMatch ? lineMatch[2] : ml;
asyncItems.push({
title: `${r.name} ${lineNum ? `(Line ${lineNum})` : ''}`,
sub: r.path,
icon: isCodeExtension(r.name) ? 'file-code' : 'file-text',
cat: 'grep',
badge: lineNum ? `L${lineNum}` : 'Grep',
grepSnippet: lineText,
grepQuery: cleanQ,
handler: () => {
recordRecentHistory({ title: r.name, path: r.path, is_dir: false, icon: 'file-text' });
openEditorWithFile(r.path, null, lineNum);
}
});
});
} else {
asyncItems.push({
title: r.name,
sub: r.path,
icon: r.is_dir ? 'folder' : (isCodeExtension(r.name) ? 'file-code' : 'file-text'),
cat: 'file',
badge: r.is_dir ? 'Folder' : formatBytes(r.size),
handler: () => {
recordRecentHistory({ title: r.name, path: r.path, is_dir: r.is_dir, icon: r.is_dir ? 'folder' : 'file-text' });
if (r.is_dir) {
navigatePane(App.activePaneIndex, r.path);
} else if (isImageExtension(r.path)) {
openImageViewer(r.path);
} else {
openEditorWithFile(r.path);
}
}
});
}
});
// Merge async results
if (cat === 'grep' || cat === 'files') {
spotlightItems = asyncItems;
} else {
// In 'all' category, append up to 20 async items
spotlightItems = [...spotlightItems.filter(i => i.cat !== 'grep' && i.cat !== 'file'), ...asyncItems.slice(0, 20)];
}
renderSpotlightResults();
}
} catch (e) {
if (e.name !== 'AbortError') {
console.warn('Spotlight search error:', e);
}
} finally {
spotlightAsyncLoading = false;
}
}, 220);
}
function recordRecentHistory(item) {
if (!item || (!item.path && !item.title)) return;
try {
const cleanPath = sanitizeCredentials(item.path);
const cleanTitle = sanitizeCredentials(item.title);
const cleanSub = sanitizeCredentials(item.sub || item.path);
let recents = JSON.parse(localStorage.getItem('cd_spotlight_recents') || '[]');
recents = recents.filter(r => r.path !== cleanPath && r.title !== cleanTitle);
recents.unshift({
title: cleanTitle,
sub: cleanSub,
path: cleanPath,
icon: item.icon || (item.is_dir ? 'folder' : 'file-text'),
cat: 'recent',
is_dir: item.is_dir !== false,
timestamp: Date.now()
});
if (recents.length > 20) recents = recents.slice(0, 20);
localStorage.setItem('cd_spotlight_recents', JSON.stringify(recents));
} catch (e) {}
}
function getRecentHistory() {
try {
return JSON.parse(localStorage.getItem('cd_spotlight_recents') || '[]');
} catch (e) {
return [];
}
}
function getBookmarksLocalCache() {
try {
return JSON.parse(localStorage.getItem('cd_bookmarks_cache') || '[]');
} catch (e) {
return [];
}
}
function buildSpotlightItems() {
const q = spotlightQuery.toLowerCase();
let pool = [];
// 1. Static Actions & Dynamic Themes
if (spotlightCurrentCat === 'all' || spotlightCurrentCat === 'actions') {
const activeChewToys = (window.installedChewToys || []).filter(p => p.is_enabled !== false && p.enabled !== false);
const activeChewtoyIds = new Set(activeChewToys.map(p => p.id));
pool.push(...SPOTLIGHT_STATIC_ACTIONS.filter(a => {
if (a.id === 'calc' && activeChewtoyIds.has('calculator')) return false;
if (a.id === 'tetradog' && (activeChewtoyIds.has('tetrion') || activeChewtoyIds.has('arcade-blocks'))) return false;
return true;
}).map(a => ({
title: a.title,
sub: a.sub,
icon: a.icon,
cat: 'action',
badge: 'Action',
handler: a.action
})));
if (App.config?.themes?.themes && Array.isArray(App.config.themes.themes)) {
App.config.themes.themes.forEach(t => {
pool.push({
title: `Theme: ${t.name}`,
sub: `Switch visual color palette to ${t.name}`,
icon: 'palette',
cat: 'action',
badge: 'Theme',
handler: () => applyTheme(t.id)
});
});
}
if (typeof getAllFleetNodes === 'function') {
const fleetNodes = getAllFleetNodes();
fleetNodes.forEach(fn => {
const verStr = fn.version ? `v${fn.version} • ` : '';
pool.push({
title: `Fleet Node: ${fn.name}`,
sub: `Switch to ${fn.endpoint_url} • ${verStr}${fn.status || 'node'} ${fn.latency_ms ? '(' + fn.latency_ms + 'ms)' : ''}`,
icon: 'network',
cat: 'action',
badge: 'Fleet',
handler: () => switchToFleetNode(fn.id)
});
});
}
// Dynamic Installed Chewtoys / Plugins (.grr)
if (activeChewToys.length > 0) {
activeChewToys.forEach(p => {
const iconUrl = getChewtoyIconUrl(p);
const shortcut = p.manifest?.integrations?.shortcut || p.integrations?.shortcut;
pool.push({
title: p.name || p.id,
sub: (shortcut ? `[${shortcut}] ` : '') + (p.description || `Launch ${p.name || p.id} v${p.version || '1.0.0'}`),
icon: iconUrl,
cat: 'action',
badge: shortcut || 'Chewtoy',
handler: () => openDynamicChewToy(p.id)
});
});
}
}
// 2. Standard & Open Paths
if (spotlightCurrentCat === 'all' || spotlightCurrentCat === 'paths') {
const userHome = `/home/${App.user?.username || 'bolt'}`;
const standardPaths = [
{ title: 'Home Directory', path: userHome, icon: 'home', sub: userHome },
{ title: 'Root Filesystem', path: '/', icon: 'hard-drive', sub: '/' },
{ title: 'Downloads', path: `${userHome}/Downloads`, icon: 'download', sub: `${userHome}/Downloads` },
{ title: 'Documents', path: `${userHome}/Documents`, icon: 'file-text', sub: `${userHome}/Documents` },
{ title: 'Projects', path: `${userHome}/projects`, icon: 'folder-git-2', sub: `${userHome}/projects` },
{ title: 'Temporary /tmp', path: '/tmp', icon: 'zap', sub: '/tmp' },
{ title: 'System Config /etc', path: '/etc', icon: 'settings', sub: '/etc' },
{ title: 'System Logs /var/log', path: '/var/log', icon: 'file-text', sub: '/var/log' }
];
// Add active open panes
App.panes.forEach((p, idx) => {
if (p.path) {
const cleanP = sanitizeCredentials(p.path);
standardPaths.push({
title: `Pane ${idx + 1}: ${cleanP.split('/').filter(Boolean).pop() || '/'}`,
path: cleanP,
icon: 'columns-2',
sub: cleanP
});
}
});
standardPaths.forEach(p => {
pool.push({
title: sanitizeCredentials(p.title),
sub: sanitizeCredentials(p.sub),
path: sanitizeCredentials(p.path),
icon: p.icon,
cat: 'path',
badge: 'Folder',
handler: () => {
recordRecentHistory({ title: p.title, path: p.path, is_dir: true, icon: p.icon });
navigatePane(App.activePaneIndex, p.path);
}
});
});
// If query looks like an absolute path or URI, offer to jump directly
if (spotlightQuery.startsWith('/') || spotlightQuery.startsWith('~') || spotlightQuery.startsWith('smb://') || spotlightQuery.startsWith('sftp://')) {
const targetP = spotlightQuery.startsWith('~') ? spotlightQuery.replace('~', userHome) : spotlightQuery;
const cleanTargetP = sanitizeCredentials(targetP);
pool.unshift({
title: `Jump to path: ${cleanTargetP}`,
sub: `Navigate active pane to ${cleanTargetP}`,
path: targetP,
icon: 'folder-symlink',
cat: 'path',
badge: 'Direct Path',
handler: () => {
recordRecentHistory({ title: `Path: ${cleanTargetP}`, path: targetP, is_dir: true, icon: 'folder' });
navigatePane(App.activePaneIndex, targetP);
}
});
}
}
// 3. Bookmarks & Mounts
if (spotlightCurrentCat === 'all' || spotlightCurrentCat === 'bookmarks') {
const bookmarks = getBookmarksLocalCache();
bookmarks.forEach(b => {
const cleanBPath = sanitizeCredentials(b.path);
const cleanBName = sanitizeCredentials(b.name || b.path);
pool.push({
title: cleanBName,
sub: `${b.protocol ? b.protocol.toUpperCase() + ' • ' : ''}${cleanBPath}`,
path: cleanBPath,
icon: 'star',
cat: 'bookmark',
badge: b.protocol || 'Bookmark',
handler: () => {
recordRecentHistory({ title: cleanBName, path: cleanBPath, is_dir: true, icon: 'star' });
if (b.has_password) {
openRemoteAuthModal(b.path, b.protocol);
} else {
navigatePane(App.activePaneIndex, b.path);
}
}
});
});
}
// 4. Recent History
if (spotlightCurrentCat === 'all' || spotlightCurrentCat === 'recent') {
const recents = getRecentHistory();
recents.forEach(r => {
pool.push({
title: sanitizeCredentials(r.title),
sub: sanitizeCredentials(r.sub || r.path),
path: sanitizeCredentials(r.path),
icon: r.icon || 'history',
cat: 'recent',
badge: 'Recent',
handler: () => {
if (r.path) {
navigatePane(App.activePaneIndex, r.path);
}
}
});
});
}
// Filter pool by query with score matching
if (q) {
spotlightItems = pool.filter(it => {
const matchTitle = it.title && it.title.toLowerCase().includes(q);
const matchSub = it.sub && it.sub.toLowerCase().includes(q);
return matchTitle || matchSub;
}).sort((a, b) => {
const aStarts = a.title && a.title.toLowerCase().startsWith(q);
const bStarts = b.title && b.title.toLowerCase().startsWith(q);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return 0;
});
} else {
// Show top curated items
spotlightItems = pool.slice(0, 30);
}
}
function renderSpotlightResults() {
const container = document.getElementById('spotlight-results');
if (!container) return;
if (spotlightItems.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 32px 16px; color: var(--text-muted);">
<div style="font-size: 28px; margin-bottom: 8px;">🔍</div>
<div style="font-size: 13px; font-weight: 600; color: var(--text-main);">No matching results found</div>
<div style="font-size: 11px; margin-top: 4px;">Try typing a path (e.g. <code>/etc</code>), action, or <code>/text</code> to grep in-file content.</div>
</div>
`;
return;
}
container.innerHTML = spotlightItems.map((it, idx) => {
const isSelected = idx === spotlightSelectedIndex;
const isImgIcon = it.icon && (it.icon.includes('.png') || it.icon.includes('.webp') || it.icon.includes('.svg') || it.icon.startsWith('/api/') || it.icon.startsWith('assets/'));
// Highlight grep query matches in snippet
let snippetHtml = '';
if (it.grepSnippet) {
let highlightedSnippet = escapeHtml(it.grepSnippet);
if (it.grepQuery) {
try {
const re = new RegExp(`(${escapeRegex(it.grepQuery)})`, 'gi');
highlightedSnippet = highlightedSnippet.replace(re, '<mark>$1</mark>');
} catch (_) {}
}
snippetHtml = `<div class="spotlight-grep-snippet">${snippetHtml ? '' : ''}${highlightedSnippet}</div>`;
}
return `
<div class="spotlight-item ${isSelected ? 'selected' : ''}" data-index="${idx}" onclick="executeSpotlightIndex(${idx})">
<div class="spotlight-item-icon">
${isImgIcon ? `<img src="${it.icon}" alt="" style="width: 16px; height: 16px; object-fit: contain;">` : `<i data-lucide="${it.icon || 'file'}"></i>`}
</div>
<div class="spotlight-item-content">
<div class="spotlight-item-title">${escapeHtml(it.title)}</div>
${it.sub ? `<div class="spotlight-item-sub">${escapeHtml(it.sub)}</div>` : ''}
${snippetHtml}
</div>
${it.badge ? `<span class="spotlight-item-badge">${escapeHtml(it.badge)}</span>` : ''}
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
const selectedEl = container.querySelector('.spotlight-item.selected');
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}
function handleSpotlightKey(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (spotlightItems.length > 0) {
spotlightSelectedIndex = (spotlightSelectedIndex + 1) % spotlightItems.length;
renderSpotlightResults();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (spotlightItems.length > 0) {
spotlightSelectedIndex = (spotlightSelectedIndex - 1 + spotlightItems.length) % spotlightItems.length;
renderSpotlightResults();
}
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const input = document.getElementById('spotlight-input');
if (input) input.blur();
if (spotlightItems.length > 0 && spotlightItems[spotlightSelectedIndex]) {
const selectedIdx = spotlightSelectedIndex;
setTimeout(() => executeSpotlightIndex(selectedIdx), 40);
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSpotlightModal();
}
}
function executeSpotlightIndex(idx) {
const item = spotlightItems[idx];
if (!item) return;
closeSpotlightModal();
if (typeof item.handler === 'function') {
item.handler();
}
}
// ---------------- RICH MEDIA & EXIF GPS INSPECTOR ----------------
let inspectorMapInstance = null;
let currentInspectorCoords = null;
function inspectCurrentImage() {
if (currentImageList && currentImageList[currentImageIndex]) {
openMediaInspector(currentImageList[currentImageIndex]);
}
}
function triggerMediaInspector() {
const pane = App.panes[App.activePaneIndex];
const item = App.contextItem || pane.entries[pane.cursorIndex];
if (item) {
openMediaInspector(item.path);
}
}
function copyToClipboardText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showToast(`Copied: ${text}`, 'info');
}).catch(() => {});
} else {
showToast(`Copied: ${text}`, 'info');
}
}
function extractDominantColors(imgEl) {
try {
const canvas = document.createElement('canvas');
canvas.width = 40;
canvas.height = 40;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgEl, 0, 0, 40, 40);
const data = ctx.getImageData(0, 0, 40, 40).data;
const colors = {};
for (let i = 0; i < data.length; i += 16) {
const r = Math.round(data[i] / 32) * 32;
const g = Math.round(data[i + 1] / 32) * 32;
const b = Math.round(data[i + 2] / 32) * 32;
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
colors[hex] = (colors[hex] || 0) + 1;
}
const sorted = Object.entries(colors)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(entry => entry[0]);
return sorted;
} catch (e) {
return [];
}
}
async function openMediaInspector(filePath) {
const fileName = filePath.split('/').pop() || filePath;
const ext = fileName.split('.').pop().toLowerCase();
const titleEl = document.getElementById('inspector-file-title');
const mainName = document.getElementById('inspector-main-name');
const mainSub = document.getElementById('inspector-main-sub');
const dlBtn = document.getElementById('inspector-file-download');
const thumbImg = document.getElementById('inspector-preview-img');
const thumbIcon = document.getElementById('inspector-preview-icon');
const paletteRow = document.getElementById('inspector-palette-row');
const secCamera = document.getElementById('inspector-section-camera');
const gridCamera = document.getElementById('inspector-camera-grid');
const secGps = document.getElementById('inspector-section-gps');
const secStream = document.getElementById('inspector-section-stream');
const gridStream = document.getElementById('inspector-stream-grid');
const gridFs = document.getElementById('inspector-fs-grid');
if (titleEl) titleEl.textContent = `Media Inspector: ${fileName}`;
if (mainName) mainName.textContent = fileName;
if (mainSub) mainSub.textContent = `Analyzing ${ext.toUpperCase()} media...`;
if (paletteRow) paletteRow.innerHTML = '';
const downloadUrl = getDownloadUrl(filePath, false);
const streamUrl = getDownloadUrl(filePath, true);
if (dlBtn) {
dlBtn.href = downloadUrl;
dlBtn.download = fileName;
}
if (secCamera) secCamera.style.display = 'none';
if (secGps) secGps.style.display = 'none';
if (secStream) secStream.style.display = 'none';
// Populate FS Info
if (gridFs) {
gridFs.innerHTML = `
<div class="inspector-grid-item">
<span class="inspector-grid-label">Full Path</span>
<span class="inspector-grid-value" style="font-size: 11px;">${escapeHtml(filePath)}</span>
</div>
<div class="inspector-grid-item">
<span class="inspector-grid-label">File Type</span>
<span class="inspector-grid-value">${escapeHtml(ext.toUpperCase())}</span>
</div>
`;
}
const isImg = isImageExtension(fileName);
const isAud = isAudioExtension(fileName);
const isVid = isVideoExtension(fileName);
if (isImg) {
if (thumbIcon) thumbIcon.style.display = 'none';
if (thumbImg) {
thumbImg.style.display = 'block';
thumbImg.src = streamUrl;
thumbImg.onload = () => {
const colors = extractDominantColors(thumbImg);
if (paletteRow && colors.length > 0) {
paletteRow.innerHTML = colors.map(hex => `
<div class="inspector-color-swatch" style="background: ${hex};" title="Click to copy ${hex}" onclick="copyToClipboardText('${hex}')"></div>
`).join('');
}
};
}
} else {
if (thumbImg) thumbImg.style.display = 'none';
if (thumbIcon) {
thumbIcon.style.display = 'block';
thumbIcon.textContent = isVid ? '🎬' : (isAud ? '🎵' : '📄');
}
}
showModal('media-inspector-modal');
if (window.lucide) lucide.createIcons();
// Load and Parse
try {
const resp = await fetch(getDownloadUrl(filePath, false), {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const buffer = await resp.arrayBuffer();
const fileSize = formatFileSize(buffer.byteLength);
if (isImg) {
// Parse EXIF
const exif = parseExifFromBuffer(buffer);
const imgWidth = exif.PixelXDimension || exif.ImageWidth;
const imgHeight = exif.PixelYDimension || exif.ImageHeight;
const mp = (imgWidth && imgHeight) ? ((imgWidth * imgHeight) / 1000000).toFixed(1) + ' MP' : '';
if (mainSub) {
mainSub.textContent = `${ext.toUpperCase()} Image • ${imgWidth ? `${imgWidth} × ${imgHeight} (${mp}) • ` : ''}${fileSize}`;
}
// Camera Details
const cameraItems = [];
if (exif.Make || exif.Model) cameraItems.push({ label: 'Camera Body', val: `${exif.Make || ''} ${exif.Model || ''}`.trim() });
if (exif.LensModel) cameraItems.push({ label: 'Lens Model', val: exif.LensModel });
if (exif.FNumber) cameraItems.push({ label: 'Aperture', val: `ƒ/${exif.FNumber}` });
if (exif.ExposureTime) cameraItems.push({ label: 'Shutter Speed', val: formatShutterSpeed(exif.ExposureTime) });
if (exif.ISOSpeedRatings) cameraItems.push({ label: 'ISO Sensitivity', val: `ISO ${exif.ISOSpeedRatings}` });
if (exif.FocalLength) cameraItems.push({ label: 'Focal Length', val: `${exif.FocalLength}mm${exif.FocalLengthIn35mmFilm ? ` (${exif.FocalLengthIn35mmFilm}mm in 35mm)` : ''}` });
if (exif.ExposureBiasValue !== undefined) cameraItems.push({ label: 'Exposure Bias', val: `${exif.ExposureBiasValue > 0 ? '+' : ''}${exif.ExposureBiasValue} EV` });
if (exif.DateTimeOriginal) cameraItems.push({ label: 'Date Taken', val: exif.DateTimeOriginal });
if (exif.Software) cameraItems.push({ label: 'Software / OS', val: exif.Software });
if (cameraItems.length > 0) {
if (secCamera) secCamera.style.display = 'block';
if (gridCamera) {
gridCamera.innerHTML = cameraItems.map(it => `
<div class="inspector-grid-item">
<span class="inspector-grid-label">${escapeHtml(it.label)}</span>
<span class="inspector-grid-value">${escapeHtml(it.val)}</span>
</div>
`).join('');
}
}
// GPS Location & Map
if (exif.gps && exif.gps.lat !== undefined && exif.gps.lon !== undefined) {
currentInspectorCoords = exif.gps;
const lat = exif.gps.lat;
const lon = exif.gps.lon;
const alt = exif.gps.altitude ? ` • Alt: ${exif.gps.altitude.toFixed(1)}m` : '';
if (secGps) secGps.style.display = 'block';
const coordsEl = document.getElementById('inspector-gps-coords');
if (coordsEl) coordsEl.textContent = `📍 ${lat.toFixed(6)}°, ${lon.toFixed(6)}°${alt}`;
const linkOsm = document.getElementById('inspector-link-osm');
if (linkOsm) linkOsm.href = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
const linkGmaps = document.getElementById('inspector-link-gmaps');
if (linkGmaps) linkGmaps.href = `https://www.google.com/maps?q=${lat},${lon}`;
const linkApple = document.getElementById('inspector-link-apple');
if (linkApple) linkApple.href = `https://maps.apple.com/?q=${lat},${lon}`;
setTimeout(() => {
if (window.L) {
const mapEl = document.getElementById('inspector-leaflet-map');
if (mapEl) {
if (inspectorMapInstance) {
inspectorMapInstance.remove();
inspectorMapInstance = null;
}
inspectorMapInstance = L.map('inspector-leaflet-map').setView([lat, lon], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(inspectorMapInstance);
L.marker([lat, lon]).addTo(inspectorMapInstance)
.bindPopup(`<b>${escapeHtml(fileName)}</b><br>${lat.toFixed(5)}, ${lon.toFixed(5)}`)
.openPopup();
}
}
}, 200);
}
} else if (isVid || isAud) {
// Video / Audio Stream Inspection
if (secStream) secStream.style.display = 'block';
if (gridStream) gridStream.innerHTML = '<div style="color: var(--text-dim);">Analyzing stream...</div>';
const tempEl = document.createElement(isVid ? 'video' : 'audio');
tempEl.src = streamUrl;
tempEl.onloadedmetadata = () => {
const durationStr = formatMediaDuration(tempEl.duration);
if (mainSub) {
mainSub.textContent = `${ext.toUpperCase()} ${isVid ? 'Video' : 'Audio'} • ${durationStr} • ${fileSize}`;
}
const streamItems = [
{ label: 'Container Format', val: ext.toUpperCase() },
{ label: 'Total Duration', val: durationStr },
{ label: 'File Size', val: fileSize }
];
if (isVid && tempEl.videoWidth) {
const resLabel = getResolutionLabel(tempEl.videoWidth, tempEl.videoHeight);
streamItems.push({ label: 'Video Dimensions', val: `${tempEl.videoWidth} × ${tempEl.videoHeight} (${resLabel})` });
const gcd = (a, b) => b ? gcd(b, a % b) : a;
const d = gcd(tempEl.videoWidth, tempEl.videoHeight);
streamItems.push({ label: 'Aspect Ratio', val: `${tempEl.videoWidth / d}:${tempEl.videoHeight / d}` });
}
if (gridStream) {
gridStream.innerHTML = streamItems.map(it => `
<div class="inspector-grid-item">
<span class="inspector-grid-label">${escapeHtml(it.label)}</span>
<span class="inspector-grid-value">${escapeHtml(it.val)}</span>
</div>
`).join('');
}
};
}
}
} catch (e) {
console.error('Media inspection error:', e);
}
if (window.lucide) lucide.createIcons();
}
function copyInspectorCoordinates() {
if (currentInspectorCoords) {
const str = `${currentInspectorCoords.lat.toFixed(6)}, ${currentInspectorCoords.lon.toFixed(6)}`;
copyToClipboardText(str);
}
}
function formatShutterSpeed(val) {
if (!val) return '';
if (val < 1) {
return `1/${Math.round(1 / val)}s`;
}
return `${val}s`;
}
function formatMediaDuration(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
function getResolutionLabel(w, h) {
const maxDim = Math.max(w, h);
if (maxDim >= 3800) return '4K UHD';
if (maxDim >= 2500) return '2K QHD';
if (maxDim >= 1900) return '1080p FHD';
if (maxDim >= 1200) return '720p HD';
if (maxDim >= 800) return '480p SD';
return 'Standard';
}
function parseExifFromBuffer(buffer) {
const view = new DataView(buffer);
const result = {};
if (view.getUint16(0, false) !== 0xFFD8) {
return result;
}
let offset = 2;
const length = view.byteLength;
while (offset < length) {
if (view.getUint8(offset) !== 0xFF) break;
const marker = view.getUint8(offset + 1);
if (marker === 0xE1) {
const exifHeader = view.getUint32(offset + 4, false);
if (exifHeader === 0x45786966 && view.getUint16(offset + 8, false) === 0x0000) {
const tiffOffset = offset + 10;
parseTiffHeader(view, tiffOffset, result);
}
break;
} else {
offset += 2 + view.getUint16(offset + 2, false);
}
}
return result;
}
function parseTiffHeader(view, tiffOffset, result) {
if (tiffOffset + 8 > view.byteLength) return;
const byteOrder = view.getUint16(tiffOffset, false);
const littleEndian = byteOrder === 0x4949;
if (view.getUint16(tiffOffset + 2, littleEndian) !== 0x002A) {
return;
}
const ifd0Offset = view.getUint32(tiffOffset + 4, littleEndian);
parseIFD(view, tiffOffset, tiffOffset + ifd0Offset, littleEndian, result);
}
function parseIFD(view, tiffOffset, ifdOffset, littleEndian, result) {
if (ifdOffset + 2 > view.byteLength) return;
const numEntries = view.getUint16(ifdOffset, littleEndian);
let offset = ifdOffset + 2;
const TAGS = {
0x010F: 'Make',
0x0110: 'Model',
0x0112: 'Orientation',
0x011A: 'XResolution',
0x011B: 'YResolution',
0x0131: 'Software',
0x0132: 'DateTime',
0x8769: 'ExifIFDPointer',
0x8825: 'GPSInfoIFDPointer',
0xA002: 'PixelXDimension',
0xA003: 'PixelYDimension',
0x0100: 'ImageWidth',
0x0101: 'ImageHeight'
};
const EXIF_TAGS = {
0x829A: 'ExposureTime',
0x829D: 'FNumber',
0x8827: 'ISOSpeedRatings',
0x9003: 'DateTimeOriginal',
0x9004: 'CreateDate',
0x9204: 'ExposureBiasValue',
0x9207: 'MeteringMode',
0x9209: 'Flash',
0x920A: 'FocalLength',
0xA405: 'FocalLengthIn35mmFilm',
0xA434: 'LensModel'
};
for (let i = 0; i < numEntries; i++) {
if (offset + 12 > view.byteLength) break;
const tag = view.getUint16(offset, littleEndian);
const type = view.getUint16(offset + 2, littleEndian);
const count = view.getUint32(offset + 4, littleEndian);
const valueOffset = view.getUint32(offset + 8, littleEndian);
const tagName = TAGS[tag] || EXIF_TAGS[tag];
if (tagName) {
result[tagName] = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
}
if (tag === 0x8769) {
parseIFD(view, tiffOffset, tiffOffset + valueOffset, littleEndian, result);
} else if (tag === 0x8825) {
parseGPSIFD(view, tiffOffset, tiffOffset + valueOffset, littleEndian, result);
}
offset += 12;
}
}
function parseGPSIFD(view, tiffOffset, ifdOffset, littleEndian, result) {
if (ifdOffset + 2 > view.byteLength) return;
const numEntries = view.getUint16(ifdOffset, littleEndian);
let offset = ifdOffset + 2;
const gps = {};
for (let i = 0; i < numEntries; i++) {
if (offset + 12 > view.byteLength) break;
const tag = view.getUint16(offset, littleEndian);
const type = view.getUint16(offset + 2, littleEndian);
const count = view.getUint32(offset + 4, littleEndian);
if (tag === 0x0001) gps.latRef = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
else if (tag === 0x0002) gps.latRaw = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
else if (tag === 0x0003) gps.lonRef = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
else if (tag === 0x0004) gps.lonRaw = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
else if (tag === 0x0006) gps.altitude = readTagValue(view, tiffOffset, offset + 8, type, count, littleEndian);
offset += 12;
}
if (Array.isArray(gps.latRaw) && gps.latRaw.length === 3 && Array.isArray(gps.lonRaw) && gps.lonRaw.length === 3) {
let lat = gps.latRaw[0] + gps.latRaw[1] / 60 + gps.latRaw[2] / 3600;
if (gps.latRef === 'S') lat = -lat;
let lon = gps.lonRaw[0] + gps.lonRaw[1] / 60 + gps.lonRaw[2] / 3600;
if (gps.lonRef === 'W') lon = -lon;
result.gps = { lat, lon, altitude: gps.altitude };
}
}
function readTagValue(view, tiffOffset, valOffset, type, count, littleEndian) {
if (type === 2) {
const ptr = count > 4 ? tiffOffset + view.getUint32(valOffset, littleEndian) : valOffset;
let str = '';
for (let i = 0; i < count - 1; i++) {
if (ptr + i < view.byteLength) {
const c = view.getUint8(ptr + i);
if (c === 0) break;
str += String.fromCharCode(c);
}
}
return str.trim();
} else if (type === 3) {
return view.getUint16(valOffset, littleEndian);
} else if (type === 4) {
return view.getUint32(valOffset, littleEndian);
} else if (type === 5 || type === 10) {
const ptr = tiffOffset + view.getUint32(valOffset, littleEndian);
if (count === 1) {
const num = type === 10 ? view.getInt32(ptr, littleEndian) : view.getUint32(ptr, littleEndian);
const den = type === 10 ? view.getInt32(ptr + 4, littleEndian) : view.getUint32(ptr + 4, littleEndian);
return den !== 0 ? num / den : 0;
} else {
const arr = [];
for (let i = 0; i < count; i++) {
const num = type === 10 ? view.getInt32(ptr + i * 8, littleEndian) : view.getUint32(ptr + i * 8, littleEndian);
const den = type === 10 ? view.getInt32(ptr + i * 8 + 4, littleEndian) : view.getUint32(ptr + i * 8 + 4, littleEndian);
arr.push(den !== 0 ? num / den : 0);
}
return arr;
}
}
return null;
}
// =========================================================================
// 🪟 IN-PANE TOOL DOCKING ENGINE (Editor, Terminal, Calc, Git)
// =========================================================================
function dockEditorToActivePane() {
dockToolToPane('editor', App.activePaneIndex);
}
function dockTerminalToActivePane() {
dockToolToPane('terminal', App.activePaneIndex);
}
function dockCalculatorToActivePane() {
dockToolToPane('calculator', App.activePaneIndex);
}
function dockTasksToActivePane() {
dockToolToPane('tasks', App.activePaneIndex);
}
function dockGitToActivePane() {
dockToolToPane('git', App.activePaneIndex);
}
function dockTetraDogToActivePane() {
dockToolToPane('tetradog', App.activePaneIndex);
}
function dockSoundDogToActivePane() {
dockToolToPane('sounddog', App.activePaneIndex);
}
function rebuildPaneDOM(paneIndex) {
const oldPaneEl = document.getElementById(`pane-${paneIndex}`);
const pane = App.panes[paneIndex];
if (!pane) return;
const termOutput = document.getElementById('terminal-output');
const drawer = document.getElementById('terminal-drawer');
if (oldPaneEl && termOutput && drawer && oldPaneEl.contains(termOutput)) {
drawer.appendChild(termOutput);
}
const newPaneEl = createPaneElement(pane, paneIndex);
if (oldPaneEl && oldPaneEl.parentNode) {
oldPaneEl.replaceWith(newPaneEl);
} else {
renderAllPanes();
return;
}
if (pane.dockedTool) {
mountDockedTool(paneIndex);
} else {
loadPaneDirectory(paneIndex, pane.path);
}
applyPaneColors();
if (window.lucide) lucide.createIcons();
}
function dockToolToPane(toolName, paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
pane.dockedTool = toolName;
localStorage.setItem(`cd_pane_docked_${paneIndex}`, toolName);
// 1. Editor: hide floating editor window & pill
if (toolName === 'editor') {
const win = document.getElementById('floating-editor-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('editor-pill');
if (pill) pill.style.display = 'none';
if (editorTabs.length === 0) {
createNewEditorTab('', 'Untitled-1', null, false, []);
}
}
// 2. NoteDog: hide floating notedog window & pill
else if (toolName === 'notedog') {
const win = document.getElementById('floating-notedog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('notedog-pill');
if (pill) pill.style.display = 'none';
}
// 3. Terminal: close the bottom slide-up drawer completely!
else if (toolName === 'terminal') {
const drawer = document.getElementById('terminal-drawer');
if (drawer) {
drawer.classList.remove('active');
drawer.classList.remove('fullscreen');
}
termOpen = false;
localStorage.setItem('cd_terminal_open', '0');
}
// 4. Calculator: hide floating calculator & pill
else if (toolName === 'calculator') {
const win = document.getElementById('floating-calculator-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('calc-pill');
if (pill) pill.style.display = 'none';
}
// 5. Tasks: hide floating task manager & backdrop
else if (toolName === 'tasks') {
closeFloatingTaskManager();
}
// 6. Git: close git modal
else if (toolName === 'git') {
closeModal('git-modal');
}
// 7. TetraDog: hide floating window & pill
else if (toolName === 'tetradog') {
const win = document.getElementById('floating-tetradog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('tetradog-pill');
if (pill) pill.style.display = 'none';
}
// 8. SoundDog: hide floating window & pill
else if (toolName === 'sounddog') {
const win = document.getElementById('floating-sounddog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('sounddog-pill');
if (pill) pill.style.display = 'none';
}
// 9. Mediaplayer: hide floating window & pill
else if (toolName === 'mediaplayer') {
const win = document.getElementById('floating-mediaplayer-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('mediaplayer-pill');
if (pill) pill.style.display = 'none';
}
// 10. Dynamic Modular ChewToy: hide floating dynamic window for this plugin
else if (toolName.startsWith('plugin:') || toolName.startsWith('chewtoy:')) {
const pluginId = toolName.replace(/^(plugin|chewtoy):/, '');
closeDynamicChewToy(pluginId);
}
// 11. Sharing Center: close shares modal if open
else if (toolName === 'shares') {
closeModal('shares-manager-modal');
}
rebuildPaneDOM(paneIndex);
showToast(`Docked ${toolName.toUpperCase()} into Pane ${paneIndex + 1}`, 'info');
}
function undockToolFromPane(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.dockedTool) return;
const tool = pane.dockedTool;
pane.dockedTool = null;
localStorage.removeItem(`cd_pane_docked_${paneIndex}`);
// If terminal was docked, return #terminal-output back to the bottom drawer!
if (tool === 'terminal') {
const drawer = document.getElementById('terminal-drawer');
const termOutput = document.getElementById('terminal-output');
if (drawer && termOutput && !drawer.contains(termOutput)) {
drawer.appendChild(termOutput);
}
}
// Cleanly restore the full file browser in this pane!
rebuildPaneDOM(paneIndex);
// Pop open the floating tool!
if (tool === 'editor') {
openFloatingEditor();
renderEditorTabs();
const activeTab = getActiveEditorTab('left');
if (activeTab) {
const textarea = document.getElementById('editor-text-left');
if (textarea) textarea.value = activeTab.content || '';
handleEditorInput('left');
}
} else if (tool === 'notedog') {
openFloatingNoteDog();
} else if (tool === 'terminal') {
toggleTerminal(true);
} else if (tool === 'calculator') {
openFloatingCalculator();
} else if (tool === 'tasks') {
openFloatingTaskManager();
} else if (tool === 'git') {
openGitManager(paneIndex, pane.path);
} else if (tool === 'tetradog') {
openTetraDog();
} else if (tool === 'sounddog') {
openSoundDog();
} else if (tool === 'mediaplayer') {
openMediaPlayer();
} else if (tool === 'renamer') {
openBatchRenamer();
} else if (tool === 'hexeditor') {
openHexEditor();
} else if (tool === 'duplicates') {
openDuplicateFinder(duplicateFinderState.path);
} else if (tool === 'tageditor') {
openTagEditor();
} else if (tool === 'logviewer') {
openLogViewer();
} else if (tool === 'splitter') {
openFileSplitterModal();
} else if (tool === 'pdf') {
openPdfToolModal();
} else if (tool === 'shares') {
openSharesManager();
} else if (tool === 'diskusage' || tool === 'du') {
openDiskUsageModal();
} else if (tool.startsWith('plugin:') || tool.startsWith('chewtoy:')) {
const pluginId = tool.replace(/^(plugin|chewtoy):/, '');
openDynamicChewToy(pluginId);
}
}
function closeDockedTool(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane) return;
const tool = pane.dockedTool;
pane.dockedTool = null;
localStorage.removeItem(`cd_pane_docked_${paneIndex}`);
if (tool === 'splitter') {
const win = document.getElementById('floating-splitter-window');
const body = document.getElementById('splitter-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'pdf') {
const win = document.getElementById('floating-pdf-window');
const body = document.getElementById('pdf-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'shares') {
const win = document.getElementById('floating-shares-window');
const body = document.getElementById('shares-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'diskusage' || tool === 'du') {
const win = document.getElementById('floating-diskusage-window');
const body = document.getElementById('diskusage-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'duplicates') {
const win = document.getElementById('floating-duplicates-window');
const body = document.getElementById('duplicates-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'tageditor') {
const win = document.getElementById('floating-tageditor-window');
const body = document.getElementById('tageditor-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'logviewer') {
const win = document.getElementById('floating-logviewer-window');
const toolbar = document.querySelector('.logviewer-toolbar');
const body = document.getElementById('logviewer-console');
if (win) {
if (toolbar && !win.contains(toolbar)) win.appendChild(toolbar);
if (body && !win.contains(body)) win.appendChild(body);
}
}
if (tool === 'renamer') {
const win = document.getElementById('floating-renamer-window');
const body = document.getElementById('renamer-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'hexeditor') {
const win = document.getElementById('floating-hexeditor-window');
const body = document.getElementById('hexeditor-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
}
if (tool === 'mediaplayer') {
const dockedVid = document.getElementById(`docked-mediaplayer-video-${paneIndex}`);
if (dockedVid) {
dockedVid.pause();
dockedVid.src = '';
}
}
if (tool === 'terminal') {
const drawer = document.getElementById('terminal-drawer');
const termOutput = document.getElementById('terminal-output');
if (drawer && termOutput && !drawer.contains(termOutput)) {
drawer.appendChild(termOutput);
}
if (termWs) {
const oldWs = termWs;
termWs = null;
oldWs.onopen = null;
oldWs.onmessage = null;
oldWs.onerror = null;
oldWs.onclose = null;
try { oldWs.close(); } catch (_) {}
}
}
rebuildPaneDOM(paneIndex);
showToast(`Closed docked ${tool.toUpperCase()} in Pane ${paneIndex + 1}`, 'info');
}
function mountDockedTool(paneIndex) {
const pane = App.panes[paneIndex];
if (!pane || !pane.dockedTool) return;
const tool = pane.dockedTool;
const mount = document.getElementById(`docked-tool-mount-${paneIndex}`);
if (!mount) return;
// Dynamic Modular Chewtoy Dock
if (tool.startsWith('plugin:') || tool.startsWith('chewtoy:')) {
const pluginId = tool.replace(/^(plugin|chewtoy):/, '');
const plugin = (window.installedChewToys || []).find(p => p.id === pluginId) || { id: pluginId, name: pluginId, entry_point: 'index.html' };
const entry = plugin.entry_point || 'index.html';
const iframeSrc = `/api/plugins/${encodeURIComponent(pluginId)}/assets/${entry}?_t=${Date.now()}`;
mount.innerHTML = `
<div style="flex: 1; position: relative; display: flex; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);">
<iframe id="docked-chewtoy-frame-${paneIndex}" src="${iframeSrc}" style="width: 100%; height: 100%; border: none; display: block;" sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"></iframe>
</div>
`;
const frame = document.getElementById(`docked-chewtoy-frame-${paneIndex}`);
if (frame) {
frame.addEventListener('load', () => {
try {
frame.contentWindow.Brum = window.Brum;
} catch (_) {}
const viewport = window.innerWidth < 600 ? 'phone' : (window.innerWidth <= 1024 ? 'tablet' : 'pc');
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches;
const context = {
activePath: App.panes[paneIndex]?.path || '/',
selectedFiles: (App.panes[paneIndex]?.selectedIndices || []).map(i => App.panes[paneIndex]?.items[i]?.path || App.panes[paneIndex]?.items[i]?.name).filter(Boolean),
paneIndex: paneIndex,
theme: App.theme || 'amber-charcoal',
pluginId: pluginId,
isDocked: true,
viewport: viewport,
isTouch: isTouch
};
brumLastContext = context;
if (window.Brum && typeof window.Brum._dispatchReady === 'function') {
window.Brum._dispatchReady(context);
}
try {
frame.contentWindow.postMessage({ type: 'BRUM_READY', context }, '*');
} catch (_) {}
});
}
return;
}
// 0. DOCKED NOTEDOG NOTES & MARKDOWN STUDIO
if (tool === 'notedog') {
mount.innerHTML = `
<div class="docked-notedog-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);">
<div class="notedog-workspace-header" style="padding: 4px 6px; min-height: 32px;">
<div class="notedog-workspace-actions" style="display: flex; align-items: center; gap: 3px;">
<button class="btn btn-icon btn-accent" onclick="saveActiveNoteDogNote()" title="Save Note (Ctrl+S)"><i data-lucide="save"></i></button>
<button class="btn btn-icon" onclick="openNoteDogTemplatePicker(event)" title="Insert Template"><i data-lucide="layout-template"></i></button>
<button class="btn btn-icon" onclick="promptCreateNote()" title="New Note (Ctrl+N)"><i data-lucide="plus"></i></button>
<button class="btn btn-icon btn-danger-hover" onclick="promptDeleteCurrentNote()" title="Delete Current Note"><i data-lucide="trash-2"></i></button>
</div>
<div style="font-size: 11px; font-weight: 600; color: var(--text-main); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" id="docked-notedog-title-${paneIndex}">
${escapeHtml(notedogState.activeNote?.name || 'NoteDog')}
</div>
<div class="layout-btn-group" style="display: flex; gap: 2px;">
<button class="btn btn-icon notedog-mode-btn ${notedogState.viewMode === 'edit' ? 'active' : ''}" id="docked-notedog-mode-edit-${paneIndex}" onclick="setNoteDogViewMode('edit')" title="Editor Only"><i data-lucide="square"></i></button>
<button class="btn btn-icon notedog-mode-btn ${(!notedogState.viewMode || notedogState.viewMode === 'split') ? 'active' : ''}" id="docked-notedog-mode-split-${paneIndex}" onclick="setNoteDogViewMode('split')" title="Split View"><i data-lucide="columns-2"></i></button>
<button class="btn btn-icon notedog-mode-btn ${notedogState.viewMode === 'preview' ? 'active' : ''}" id="docked-notedog-mode-preview-${paneIndex}" onclick="setNoteDogViewMode('preview')" title="Preview"><i data-lucide="book-open"></i></button>
</div>
</div>
<div style="flex: 1; display: flex; overflow: hidden; height: 100%;" id="docked-notedog-body-${paneIndex}">
<!-- Embedded NoteDog workspace -->
</div>
</div>
`;
if (notedogState.notebooks.length === 0) {
loadNoteDogHierarchy().then(() => mountDockedNoteDog(paneIndex));
} else {
mountDockedNoteDog(paneIndex);
}
}
// 1. DOCKED CODE EDITOR
else if (tool === 'editor') {
const activeTab = getActiveEditorTab('left');
mount.innerHTML = `
<div style="padding: 6px 10px; background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: 11px;">
<div style="display: flex; gap: 4px; align-items: center;">
<button class="btn btn-xs btn-accent" onclick="saveDockedEditorTab(${paneIndex})"><i data-lucide="save" style="width:11px; height:11px;"></i> Save</button>
<button class="btn btn-xs" onclick="createNewDockedEditorTab(${paneIndex})"><i data-lucide="plus" style="width:11px; height:11px;"></i> New</button>
</div>
<div style="font-size: 11px; color: var(--text-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" id="docked-editor-file-label-${paneIndex}">
${escapeHtml(activeTab ? activeTab.filename : 'Untitled')}
</div>
</div>
<div class="editor-tab-strip" style="background: var(--bg-panel); border-bottom: 1px solid var(--border); overflow-x: auto; display: flex; align-items: center; padding: 2px 6px;">
<div class="editor-tabs-scroll" id="docked-editor-tabs-${paneIndex}" style="display: flex; gap: 4px; padding: 2px 0;"></div>
</div>
<div style="flex: 1; position: relative; display: flex; overflow: hidden; height: 100%;">
<textarea id="docked-editor-textarea-${paneIndex}" class="editor-textarea" style="width: 100%; height: 100%; resize: none; background: #181a1f; color: #f8fafc; font-family: var(--font-mono); font-size: 12px; line-height: 1.4; padding: 10px; border: none; outline: none; white-space: pre;" oninput="syncDockedEditorInput(${paneIndex}, this.value)"></textarea>
</div>
`;
renderDockedEditorTabs(paneIndex);
const textarea = document.getElementById(`docked-editor-textarea-${paneIndex}`);
if (textarea && activeTab) {
textarea.value = activeTab.content || '';
}
}
// 2. DOCKED TERMINAL CONSOLE (REAL XTERM.JS PTY)
else if (tool === 'terminal') {
mount.innerHTML = `
<div id="docked-term-host-${paneIndex}" style="flex: 1; width: 100%; height: 100%; position: relative; overflow: hidden; background: #090a0d;"></div>
`;
const host = document.getElementById(`docked-term-host-${paneIndex}`);
const termOutput = ensureTerminalOutputElement();
if (host && termOutput) {
host.appendChild(termOutput);
termOutput.style.width = '100%';
termOutput.style.height = '100%';
termOutput.style.display = 'flex';
termOutput.style.flexDirection = 'column';
}
initTerminalUI();
const cwd = (pane && !pane.path.includes('://')) ? pane.path : (getUserDefaultHomeDir() || '/');
const isWsActive = termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING);
if (!isWsActive) {
connectTerminal(cwd);
}
// Dynamic resize observer keeping docked terminal & TUI apps (nano/vim) perfectly sized
if (window.ResizeObserver && host) {
if (host._termResizeObs) host._termResizeObs.disconnect();
let resizeTimer = null;
host._termResizeObs = new ResizeObserver(() => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
}, 50);
});
host._termResizeObs.observe(host);
}
setTimeout(() => {
if (termFitAddon) {
try { termFitAddon.fit(); } catch (e) {}
}
if (termInstance) {
termInstance.focus();
}
}, 100);
}
// 3. DOCKED CALCULATOR (COMPLETE WITH CONVERTERS, STORAGE UNITS & HISTORY)
else if (tool === 'calculator') {
mount.innerHTML = `
<div class="docked-calc-box" style="padding: 12px 16px; display: flex; flex-direction: column; width: 100%; max-width: 340px; height: 100%; gap: 8px; background: var(--bg-panel); overflow-y: auto;">
<!-- Screen / Display -->
<div class="calc-display-panel">
<div class="calc-expression-line" id="calc-expr-display"> </div>
<div class="calc-result-row">
<input type="text" class="calc-result-input" id="calc-display-input" value="0" autocomplete="off" spellcheck="false" readonly />
<button class="btn btn-icon btn-xs calc-copy-btn" onclick="copyCalcResult()" title="Copy Result to Clipboard"><i data-lucide="copy" style="width: 13px;"></i></button>
</div>
</div>
<!-- Live Byte & Base Converter Bar -->
<div class="calc-converters-bar" id="calc-converters-bar">
<div class="calc-conv-item" title="Human-readable File Size"><span class="calc-conv-lbl">SIZE:</span> <span class="calc-conv-val" id="calc-conv-size">0 B</span></div>
<div class="calc-conv-item" title="Hexadecimal"><span class="calc-conv-lbl">HEX:</span> <span class="calc-conv-val" id="calc-conv-hex">0x0</span></div>
<div class="calc-conv-item" title="Octal (chmod mode)"><span class="calc-conv-lbl">OCT:</span> <span class="calc-conv-val" id="calc-conv-oct">0</span></div>
<div class="calc-conv-item" title="Binary"><span class="calc-conv-lbl">BIN:</span> <span class="calc-conv-val" id="calc-conv-bin">0b0</span></div>
</div>
<!-- Mode & Quick Byte Multipliers Row -->
<div class="calc-units-row">
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendUnit('KB')" title="Multiply by 1024 (KB)">KB</button>
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendUnit('MB')" title="Multiply by 1024² (MB)">MB</button>
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendUnit('GB')" title="Multiply by 1024³ (GB)">GB</button>
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendUnit('TB')" title="Multiply by 1024⁴ (TB)">TB</button>
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendFunc('sqrt(')" title="Square Root">√</button>
<button class="btn btn-xs calc-unit-btn" onclick="calcAppendOp('^')" title="Exponent / Power">xʸ</button>
</div>
<!-- Main Keypad Grid -->
<div class="calc-keypad-grid">
<button class="btn calc-btn calc-btn-fn" onclick="calcClearAll()">C</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcClearEntry()">CE</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcBackspace()" title="Backspace"><i data-lucide="delete" style="width: 14px;"></i></button>
<button class="btn calc-btn calc-btn-op" onclick="calcAppendOp('/')">÷</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcAppendChar('(')">(</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcAppendChar(')')">)</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcAppendOp('%')">%</button>
<button class="btn calc-btn calc-btn-op" onclick="calcAppendOp('*')">×</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('7')">7</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('8')">8</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('9')">9</button>
<button class="btn calc-btn calc-btn-op" onclick="calcAppendOp('-')">−</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('4')">4</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('5')">5</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('6')">6</button>
<button class="btn calc-btn calc-btn-op" onclick="calcAppendOp('+')">+</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('1')">1</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('2')">2</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('3')">3</button>
<button class="btn calc-btn calc-btn-equals" onclick="calcEvaluate()">=</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcToggleSign()">±</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendNum('0')">0</button>
<button class="btn calc-btn calc-btn-num" onclick="calcAppendDot()">.</button>
<button class="btn calc-btn calc-btn-fn" onclick="calcAppendPi()" title="Pi (3.14159...)">π</button>
</div>
<!-- History Tape Drawer Toggle -->
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 4px; border-top: 1px solid var(--border);">
<button class="btn btn-xs btn-outline" onclick="toggleCalcHistory()" style="font-size: 10px;"><i data-lucide="history" style="width: 11px; height: 11px;"></i> History</button>
<span style="font-size: 10px; color: var(--text-dim);">Keyboard Active</span>
</div>
</div>
`;
calcUpdateDisplay();
}
// 4. DOCKED TASKS & BACKGROUND TRANSFERS
else if (tool === 'tasks') {
mount.innerHTML = `
<div style="padding: 12px; display: flex; flex-direction: column; height: 100%; gap: 10px; background: var(--bg-panel); overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; background: var(--bg-dark); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="task-status-indicator" id="docked-task-indicator-${paneIndex}"></span>
<span style="font-weight: 700; font-size: 12px; display: flex; align-items: center; gap: 4px;"><img src="assets/task.webp" alt="Tasks" style="width: 14px; height: 14px; object-fit: contain;"> <span id="docked-task-title-${paneIndex}">Transfers (0 active)</span></span>
<span class="task-speed-badge" id="docked-task-speed-${paneIndex}">0 B/s</span>
</div>
<div style="display: flex; gap: 4px;">
<button class="btn btn-xs btn-outline" onclick="pollTasks()" title="Refresh Status"><i data-lucide="rotate-cw" style="width:11px; height:11px;"></i></button>
</div>
</div>
<!-- Batch Progress Summary -->
<div class="task-batch-summary" style="margin: 0;">
<div class="task-progress-meta">
<span id="docked-task-batch-files-${paneIndex}">0 / 0 files</span>
<span id="docked-task-batch-percent-${paneIndex}">0%</span>
<span id="docked-task-batch-bytes-${paneIndex}">0 B / 0 B</span>
<span id="docked-task-batch-eta-${paneIndex}" style="color: var(--accent); font-weight: 700;">ETA: --:--</span>
</div>
<div class="task-progress-bar-track">
<div class="task-progress-bar-fill" id="docked-task-batch-fill-${paneIndex}" style="width: 0%;"></div>
</div>
</div>
<!-- Active File Card -->
<div class="task-active-file-card" style="margin: 0;">
<div class="task-file-info">
<span style="display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;">
<i data-lucide="file-text" style="width: 14px; height: 14px; color: var(--accent);"></i>
<span class="task-cur-filename" id="docked-task-filename-${paneIndex}">Idle (No active file)</span>
</span>
<span class="task-cur-speed" id="docked-task-cur-speed-${paneIndex}">0 B/s</span>
</div>
<div class="task-progress-bar-track task-file-track">
<div class="task-progress-bar-fill task-file-fill" id="docked-task-file-fill-${paneIndex}" style="width: 0%;"></div>
</div>
<div class="task-file-stats">
<span id="docked-task-file-bytes-${paneIndex}">0 B / 0 B</span>
<span id="docked-task-file-percent-${paneIndex}">0%</span>
</div>
</div>
<!-- Queue List -->
<div style="font-weight: 700; font-size: 11px; color: var(--text-dim); margin-top: 4px;">Transfer Queue & History:</div>
<div id="docked-task-queue-${paneIndex}" class="task-queue-list" style="flex: 1; min-height: 120px; max-height: none; background: var(--bg-dark); border-radius: 6px; border: 1px solid var(--border); padding: 6px; overflow-y: auto;"></div>
</div>
`;
if (lastKnownTasksList) {
renderDockedTasksForPane(paneIndex, lastKnownTasksList);
} else {
pollTasks();
}
}
// 5. DOCKED GIT MANAGER
else if (tool === 'git') {
mount.innerHTML = `
<div style="padding: 10px; display: flex; flex-direction: column; height: 100%; gap: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 700; font-size: 11px;">Git Working Tree: <span id="docked-git-branch" class="badge">main</span></span>
<button class="btn btn-xs btn-accent" onclick="openGitManager(${paneIndex})">Open Full Git Manager</button>
</div>
<div id="docked-git-file-list" style="flex: 1; overflow-y: auto; background: var(--bg-dark); border-radius: 4px; border: 1px solid var(--border); padding: 4px;">Loading repository status...</div>
</div>
`;
loadGitStatusForDocked(paneIndex, pane.path);
}
// 6. DOCKED TETRADOG (CLASSIC ARCADE CHEWTOY)
else if (tool === 'tetradog') {
mount.innerHTML = `
<div class="docked-tetradog-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);">
<div style="padding: 4px 8px; min-height: 32px; background: var(--bg-dark); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 6px;">
<img src="assets/amber-tetris.webp" alt="TetraDog" style="width: 14px; height: 14px; object-fit: contain;">
<span style="font-size: 11px; font-weight: 700; color: var(--accent);">TetraDog</span>
<span class="tetradog-mode-badge" id="docked-tetradog-mode-${paneIndex}">MARATHON</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-xs btn-accent" onclick="startTetraDogGame()"><i data-lucide="play" style="width: 11px;"></i> Play</button>
<button class="btn btn-xs btn-outline" onclick="setTetraDogView('leaderboard')"><i data-lucide="trophy" style="width: 11px;"></i> Scores</button>
</div>
</div>
<div style="flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #090a0f; padding: 6px;" id="docked-tetradog-host-${paneIndex}"></div>
</div>
`;
setTimeout(() => {
mountDockedTetraDog(paneIndex);
}, 50);
}
// 7. DOCKED AUDIOPLAYER (AUDIO PLAYER & JUKEBOX)
else if (tool === 'sounddog') {
mount.innerHTML = `
<div class="docked-sounddog-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel); padding: 8px;" id="docked-sounddog-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedSoundDog(paneIndex);
}, 50);
}
// 8. DOCKED MEDIAPLAYER (UNIVERSAL VIDEO & MEDIA ENGINE)
else if (tool === 'mediaplayer') {
mount.innerHTML = `
<div class="docked-mediaplayer-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: #000; position: relative;">
<!-- Video Stage -->
<div class="docked-mediaplayer-stage" style="flex: 1; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #000;" id="docked-mediaplayer-host-${paneIndex}">
<video id="docked-mediaplayer-video-${paneIndex}" playsinline style="width: 100%; height: 100%; object-fit: contain;" onclick="toggleMediaPlay()"></video>
</div>
<!-- In-Pane Controls Toolbar -->
<div class="docked-mediaplayer-controls" style="background: var(--bg-dark); border-top: 1px solid var(--border); padding: 6px 10px; display: flex; flex-direction: column; gap: 4px; flex-shrink: 0;">
<!-- Scrubber Progress Bar -->
<div class="mediaplayer-scrubber-wrapper" style="height: 12px; display: flex; align-items: center; cursor: pointer;" onclick="seekMedia(event)">
<div class="mediaplayer-scrubber-track" id="docked-mediaplayer-track-${paneIndex}" style="height: 4px; background: rgba(255,255,255,0.15); border-radius: 2px; width: 100%; position: relative; overflow: hidden;">
<div class="mediaplayer-scrubber-progress" id="docked-mediaplayer-progress-${paneIndex}" style="height: 100%; background: var(--accent); width: 0%;"></div>
</div>
</div>
<!-- Action Buttons & Time Row -->
<div style="display: flex; align-items: center; justify-content: space-between; gap: 6px;">
<div style="display: flex; align-items: center; gap: 4px; overflow: hidden; flex: 1;">
<button class="btn btn-icon btn-xs" onclick="navMediaPlaylist(-1)" title="Previous Track"><i data-lucide="skip-back" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-icon btn-xs" onclick="skipMedia(-10)" title="Rewind 10s"><i data-lucide="rotate-ccw" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-accent btn-xs" id="docked-mediaplayer-play-btn-${paneIndex}" onclick="toggleMediaPlay()" title="Play/Pause"><i data-lucide="${mediaplayerState.isPlaying ? 'pause' : 'play'}" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-icon btn-xs" onclick="skipMedia(10)" title="Forward 10s"><i data-lucide="rotate-cw" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-icon btn-xs" onclick="navMediaPlaylist(1)" title="Next Track"><i data-lucide="skip-forward" style="width: 12px; height: 12px;"></i></button>
<div class="mediaplayer-time-display" style="font-size: 10px; margin-left: 4px; white-space: nowrap;">
<span id="docked-mediaplayer-time-cur-${paneIndex}">00:00</span>
<span style="opacity: 0.4;">/</span>
<span id="docked-mediaplayer-time-tot-${paneIndex}">00:00</span>
</div>
<span id="docked-mediaplayer-track-title-${paneIndex}" style="font-size: 10.5px; font-weight: 600; color: var(--text-dim); margin-left: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
</div>
<div style="display: flex; align-items: center; gap: 4px; flex-shrink: 0;">
<button class="btn btn-icon btn-xs" onclick="toggleMediaMute()" title="Mute/Unmute"><i data-lucide="${mediaplayerState.isMuted ? 'volume-x' : 'volume-2'}" id="docked-mediaplayer-vol-icon-${paneIndex}" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-icon btn-xs" onclick="cycleMediaAspectRatio()" title="Aspect Ratio"><i data-lucide="ratio" style="width: 12px; height: 12px;"></i></button>
<button class="btn btn-icon btn-xs" onclick="toggleMediaFullscreen()" title="Fullscreen"><i data-lucide="maximize" style="width: 12px; height: 12px;"></i></button>
</div>
</div>
</div>
</div>
`;
setTimeout(() => {
mountDockedMediaPlayer(paneIndex);
}, 50);
}
// 9. DOCKED BATCH RENAMER
else if (tool === 'renamer') {
mount.innerHTML = `
<div class="docked-renamer-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-renamer-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedRenamer(paneIndex);
}, 50);
}
// 10. DOCKED HEX EDITOR
else if (tool === 'hexeditor') {
mount.innerHTML = `
<div class="docked-hexeditor-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-hexeditor-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedHexEditor(paneIndex);
}, 50);
}
// 11. DOCKED DUPLICATE FINDER
else if (tool === 'duplicates') {
mount.innerHTML = `
<div class="docked-duplicates-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-duplicates-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedDuplicateFinder(paneIndex);
}, 50);
}
// 12. DOCKED TAG EDITOR
else if (tool === 'tageditor') {
mount.innerHTML = `
<div class="docked-tageditor-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-tageditor-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedTagEditor(paneIndex);
}, 50);
}
// 13. DOCKED LOG VIEWER
else if (tool === 'logviewer') {
mount.innerHTML = `
<div class="docked-logviewer-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-logviewer-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedLogViewer(paneIndex);
}, 50);
}
// 14. DOCKED SHARING CENTER
else if (tool === 'shares') {
mount.innerHTML = `
<div class="docked-shares-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-shares-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedShares(paneIndex);
}, 50);
}
// 15. DOCKED 3D CAD STUDIO
else if (tool === 'cad') {
mount.innerHTML = `
<div class="docked-cad-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-cad-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedCadStudio(paneIndex);
}, 50);
}
// 16. DOCKED FILE SPLITTER & COMBINER
else if (tool === 'splitter') {
mount.innerHTML = `
<div class="docked-splitter-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-splitter-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedSplitter(paneIndex);
}, 50);
}
// 17. DOCKED PDF STUDIO
else if (tool === 'pdf') {
mount.innerHTML = `
<div class="docked-pdf-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-pdf-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedPdf(paneIndex);
}, 50);
}
// 18. DOCKED DISK USAGE & TREEMAP ANALYZER
else if (tool === 'diskusage' || tool === 'du') {
mount.innerHTML = `
<div class="docked-diskusage-box" style="display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-diskusage-host-${paneIndex}"></div>
`;
setTimeout(() => {
mountDockedDiskUsage(paneIndex);
}, 50);
}
if (window.lucide) lucide.createIcons({ root: mount });
}
function renderDockedTasksPanes(list) {
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'tasks') {
renderDockedTasksForPane(idx, list);
}
});
}
function renderDockedTasksForPane(paneIndex, list) {
const mount = document.getElementById(`docked-tool-mount-${paneIndex}`);
if (!mount) return;
const running = list.filter(t => t.status === 'running');
const totalSpeed = running.reduce((acc, t) => acc + (t.speed_bytes_per_sec || 0), 0);
const speedStr = totalSpeed > 0 ? `${formatBytes(totalSpeed)}/s` : '0 B/s';
const titleEl = document.getElementById(`docked-task-title-${paneIndex}`);
const indEl = document.getElementById(`docked-task-indicator-${paneIndex}`);
const speedEl = document.getElementById(`docked-task-speed-${paneIndex}`);
if (titleEl) titleEl.textContent = `Transfers (${running.length} active${list.length > running.length ? `, ${list.length - running.length} done` : ''})`;
if (indEl) {
if (running.length > 0) indEl.classList.add('active');
else indEl.classList.remove('active');
}
if (speedEl) {
speedEl.textContent = speedStr;
speedEl.style.display = totalSpeed > 0 ? 'inline-block' : 'none';
}
let totalBatchBytes = 0;
let totalProcessedBytes = 0;
let totalBatchFiles = 0;
let totalProcessedFiles = 0;
let totalVerified = 0;
let activeCurrentFile = null;
let activeCurBytes = 0;
let activeCurTotal = 0;
let activeSpeed = 0;
let activeCurHash = null;
const activeBatchList = running.length > 0 ? running : list;
activeBatchList.forEach(t => {
const isDone = t.status === 'completed';
const tBytes = t.total_bytes || (isDone ? (t.bytes_processed || 0) : 0);
const pBytes = isDone ? (t.total_bytes || t.bytes_processed || 0) : (t.bytes_processed || 0);
totalBatchBytes += tBytes;
totalProcessedBytes += pBytes;
totalBatchFiles += t.total_files || 1;
totalProcessedFiles += isDone ? (t.total_files || 1) : (t.files_processed || 0);
totalVerified += t.verified_files || 0;
if (t.status === 'running' && !activeCurrentFile) {
activeCurrentFile = t.phase_text ? t.phase_text : sanitizeCredentials(t.current_file || t.name);
activeCurBytes = t.current_file_bytes || t.bytes_processed || 0;
activeCurTotal = t.current_file_total_bytes || t.total_bytes || 0;
activeSpeed = t.speed_bytes_per_sec || 0;
activeCurHash = t.last_hash;
}
});
const overallPercent = totalBatchBytes > 0
? Math.min(100, Math.round((totalProcessedBytes / totalBatchBytes) * 100))
: (running.length === 0 && list.length > 0 ? 100 : 0);
const batchFill = document.getElementById(`docked-task-batch-fill-${paneIndex}`);
const batchPercent = document.getElementById(`docked-task-batch-percent-${paneIndex}`);
const batchFiles = document.getElementById(`docked-task-batch-files-${paneIndex}`);
const batchBytes = document.getElementById(`docked-task-batch-bytes-${paneIndex}`);
const batchEta = document.getElementById(`docked-task-batch-eta-${paneIndex}`);
if (batchFill) batchFill.style.width = `${overallPercent}%`;
if (batchPercent) batchPercent.textContent = `${overallPercent}%`;
if (batchFiles) batchFiles.textContent = `${totalProcessedFiles} / ${totalBatchFiles} files${totalVerified > 0 ? ` (🛡️ ${totalVerified} SHA-256 verified)` : ''}`;
if (batchBytes) batchBytes.textContent = `${formatBytes(totalProcessedBytes)} / ${formatBytes(totalBatchBytes)}`;
if (batchEta) {
if (running.length > 0 && totalSpeed > 0 && totalBatchBytes > totalProcessedBytes) {
const etaSec = Math.round((totalBatchBytes - totalProcessedBytes) / totalSpeed);
const mins = Math.floor(etaSec / 60);
const secs = etaSec % 60;
batchEta.textContent = `ETA: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
} else if (running.length === 0) {
batchEta.textContent = list.length > 0 ? (totalVerified > 0 ? '✓ Verified Complete' : '✓ Completed') : 'Idle';
} else {
batchEta.textContent = 'Processing...';
}
}
const curNameEl = document.getElementById(`docked-task-filename-${paneIndex}`);
const curSpeedEl = document.getElementById(`docked-task-cur-speed-${paneIndex}`);
const curFillEl = document.getElementById(`docked-task-file-fill-${paneIndex}`);
const curBytesEl = document.getElementById(`docked-task-file-bytes-${paneIndex}`);
const curPercentEl = document.getElementById(`docked-task-file-percent-${paneIndex}`);
if (activeCurrentFile) {
if (curNameEl) {
curNameEl.innerHTML = `
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(activeCurrentFile)}</div>
${activeCurHash ? `<div style="font-size: 9px; color: #a3e635; font-family: var(--font-mono); margin-top: 2px;">🔑 ${escapeHtml(activeCurHash)}</div>` : ''}
`;
}
if (curSpeedEl) curSpeedEl.textContent = activeSpeed > 0 ? `${formatBytes(activeSpeed)}/s` : '';
const filePct = activeCurTotal > 0 ? Math.min(100, Math.round((activeCurBytes / activeCurTotal) * 100)) : (running.length > 0 ? 50 : 0);
if (curFillEl) curFillEl.style.width = `${filePct}%`;
if (curBytesEl) curBytesEl.textContent = activeCurTotal > 0 ? `${formatBytes(activeCurBytes)} / ${formatBytes(activeCurTotal)}` : `${formatBytes(activeCurBytes)}`;
if (curPercentEl) curPercentEl.textContent = `${filePct}%`;
} else {
const lastHash = list.find(t => t.last_hash)?.last_hash;
if (curNameEl) {
if (running.length === 0 && list.length > 0) {
curNameEl.innerHTML = `
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">All operations completed</div>
${lastHash ? `<div style="font-size: 9px; color: #a3e635; font-family: var(--font-mono); margin-top: 2px;">🔑 ${escapeHtml(lastHash)}</div>` : ''}
`;
} else {
curNameEl.textContent = running.length === 0 ? 'No active file transfer' : 'Processing file transfer...';
}
}
if (curSpeedEl) curSpeedEl.textContent = '';
if (curFillEl) curFillEl.style.width = running.length === 0 && list.length > 0 ? '100%' : '0%';
if (curBytesEl) curBytesEl.textContent = running.length === 0 && list.length > 0 ? `${formatBytes(totalBatchBytes)} / ${formatBytes(totalBatchBytes)}` : '0 B / 0 B';
if (curPercentEl) curPercentEl.textContent = running.length === 0 && list.length > 0 ? '100%' : '0%';
}
const queueList = document.getElementById(`docked-task-queue-${paneIndex}`);
if (queueList) {
if (list.length === 0) {
queueList.innerHTML = '<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 11px;">No active background jobs</div>';
} else {
queueList.innerHTML = list.map(t => {
const pct = t.total_bytes > 0 ? Math.min(100, Math.round((t.bytes_processed / t.total_bytes) * 100)) : (t.status === 'completed' ? 100 : 0);
const badgeClass = `task-badge-${t.status || 'running'}`;
const isRunning = t.status === 'running';
const isPaused = t.status === 'paused';
const safeName = sanitizeCredentials(t.name);
const safeSrc = sanitizeCredentials(t.source);
const safeDest = sanitizeCredentials(t.destination);
return `
<div class="task-queue-item" style="margin-bottom: 6px;">
<div class="task-queue-details">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden;">
<span class="task-queue-name" title="${escapeHtml(safeName)}">${escapeHtml(safeName)}</span>
${isRunning && t.speed_bytes_per_sec > 0 ? `<span style="font-size: 9px; font-family: var(--font-mono); color: var(--accent); font-weight: 700;">⚡ ${formatBytes(t.speed_bytes_per_sec)}/s</span>` : ''}
${(t.paranoid || t.verified_files > 0 || t.last_hash) ? `<span class="task-queue-badge" style="background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); font-size: 8.5px; padding: 1px 4px; white-space: nowrap;">🛡️ SHA-256 ${t.verified_files ? `(${t.verified_files} OK)` : ''}</span>` : ''}
</div>
<span class="task-queue-badge ${badgeClass}">${escapeHtml(t.status)}</span>
</div>
<div class="task-queue-sub" title="${escapeHtml(safeSrc)} ➔ ${escapeHtml(safeDest)}">${escapeHtml(safeSrc)} ➔ ${escapeHtml(safeDest)}</div>
${t.last_hash ? `<div style="font-size: 9px; color: #a3e635; font-family: var(--font-mono); margin-top: 1px; word-break: break-all;">✓ ${escapeHtml(t.last_hash)}</div>` : ''}
${isRunning || isPaused ? `
<div style="height: 3px; background: rgba(255,255,255,0.06); border-radius: 2px; margin-top: 3px; overflow: hidden;">
<div style="height: 100%; width: ${pct}%; background: var(--accent);"></div>
</div>
` : ''}
</div>
<div style="display: flex; gap: 4px; align-items: center; flex-shrink: 0;">
${isRunning ? `
<button class="btn btn-xs btn-icon" onclick="pauseTask('${t.id}')" title="Pause"><i data-lucide="pause" style="width: 10px; height: 10px;"></i></button>
<button class="btn btn-xs btn-icon btn-danger" onclick="cancelTask('${t.id}')" title="Cancel"><i data-lucide="x" style="width: 10px; height: 10px;"></i></button>
` : (isPaused ? `
<button class="btn btn-xs btn-icon" onclick="resumeTask('${t.id}')" title="Resume"><i data-lucide="play" style="width: 10px; height: 10px;"></i></button>
<button class="btn btn-xs btn-icon btn-danger" onclick="cancelTask('${t.id}')" title="Cancel"><i data-lucide="x" style="width: 10px; height: 10px;"></i></button>
` : '')}
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: queueList });
}
}
}
function renderDockedEditorTabs(paneIndex) {
const container = document.getElementById(`docked-editor-tabs-${paneIndex}`);
if (!container) return;
container.innerHTML = editorTabs.map(tab => {
const isActive = tab.id === activeTabIdLeft;
const isDirty = tab.isDirty;
const icon = getFileIconForExtension(tab.path || tab.filename);
return `
<div class="editor-tab ${isActive ? 'active' : ''}" style="padding: 3px 8px; font-size: 11px; display: flex; align-items: center; gap: 4px; cursor: pointer;" onclick="switchDockedEditorTab('${tab.id}', ${paneIndex})">
<i data-lucide="${icon}" style="width: 12px; height: 12px;"></i>
<span style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(tab.filename)}</span>
${isDirty ? '<span style="color:var(--accent); font-weight:bold;">●</span>' : ''}
<span style="cursor: pointer; opacity: 0.7; margin-left: 2px;" onclick="event.stopPropagation(); closeDockedEditorTab('${tab.id}', ${paneIndex})"><i data-lucide="x" style="width: 10px; height: 10px;"></i></span>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: container });
}
function switchDockedEditorTab(tabId, paneIndex) {
const tab = getEditorTabById(tabId);
if (!tab) return;
const prevTab = getActiveEditorTab('left');
const textarea = document.getElementById(`docked-editor-textarea-${paneIndex}`);
if (prevTab && textarea) {
prevTab.content = textarea.value;
}
activeTabIdLeft = tabId;
if (textarea) {
textarea.value = tab.content || '';
}
const lbl = document.getElementById(`docked-editor-file-label-${paneIndex}`);
if (lbl) lbl.textContent = tab.filename;
renderDockedEditorTabs(paneIndex);
}
function createNewDockedEditorTab(paneIndex) {
const newTab = createNewEditorTab('', null, null, false, []);
switchDockedEditorTab(newTab.id, paneIndex);
}
function closeDockedEditorTab(tabId, paneIndex) {
closeEditorTab(tabId);
if (editorTabs.length === 0) {
createNewEditorTab('', 'Untitled-1', null, false, []);
}
const currentTab = getActiveEditorTab('left');
if (currentTab) {
switchDockedEditorTab(currentTab.id, paneIndex);
}
}
function saveDockedEditorTab(paneIndex) {
const tab = getActiveEditorTab('left');
const textarea = document.getElementById(`docked-editor-textarea-${paneIndex}`);
if (tab && textarea) {
tab.content = textarea.value;
}
saveActiveEditorTab();
renderDockedEditorTabs(paneIndex);
}
function syncDockedEditorInput(paneIndex, val) {
const tab = getActiveEditorTab('left');
if (tab) {
tab.content = val;
tab.isDirty = true;
renderDockedEditorTabs(paneIndex);
}
}
// =========================================================================
// 🌲 GIT CLIENT & VERSION CONTROL ENGINE
// =========================================================================
let currentGitRepoPath = '';
let currentGitStatusData = null;
let currentGitDiffFile = null;
async function fetchGitStatusForPane(paneIndex, path) {
if (!path || path.startsWith('smb://') || path.startsWith('sftp://') || path.startsWith('client://')) return;
try {
const resp = await fetch(`/api/git/status?path=${encodeURIComponent(path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
if (data.is_repo) {
const container = document.getElementById(`pane-crumbs-${paneIndex}`);
if (container) {
const badge = document.createElement('span');
badge.className = 'pane-git-badge';
badge.title = `Git Branch: ${data.branch} | ${data.files.length} changed files`;
badge.onclick = (e) => { e.stopPropagation(); openGitManager(paneIndex, data.root_path || path); };
badge.innerHTML = `
<i data-lucide="git-branch" style="width:10px; height:10px;"></i>
<span>${escapeHtml(data.branch)}</span>
${data.ahead > 0 ? `<span style="color:#22c55e; font-size: 8.5px;">⇡${data.ahead}</span>` : ''}
${data.behind > 0 ? `<span style="color:#ef4444; font-size: 8.5px;">⇣${data.behind}</span>` : ''}
${!data.is_clean ? `<span style="color:#ef4444; font-size:10px; line-height:1;">●</span>` : ''}
`;
container.appendChild(badge);
if (window.lucide) lucide.createIcons({ root: badge });
}
}
}
} catch (e) {}
}
async function openGitManager(paneIndex = 0, prefillPath = null) {
const path = prefillPath || App.panes[paneIndex]?.path || '/';
currentGitRepoPath = path;
showModal('git-modal');
switchGitTab('status');
await loadGitStatus(path);
}
async function loadGitStatus(repoPath) {
const branchBadge = document.getElementById('git-modal-branch-badge');
const countEl = document.getElementById('git-changes-count');
const filesList = document.getElementById('git-files-list');
const diffViewer = document.getElementById('git-diff-viewer');
const diffHdr = document.getElementById('git-diff-filename');
if (filesList) filesList.innerHTML = '<div style="padding: 12px; color: var(--text-dim); text-align: center;">Loading git changes...</div>';
try {
const resp = await fetch(`/api/git/status?path=${encodeURIComponent(repoPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) {
if (filesList) filesList.innerHTML = '<div style="padding: 12px; color: var(--danger); text-align: center;">Not a git repository.</div>';
return;
}
const data = await resp.json();
currentGitStatusData = data;
currentGitRepoPath = data.root_path || repoPath;
if (branchBadge) {
branchBadge.textContent = data.branch || 'HEAD';
branchBadge.title = `${data.ahead} ahead, ${data.behind} behind`;
}
if (countEl) {
countEl.textContent = `${data.files.length} Changes (${data.staged_count} staged)`;
}
renderGitFiles(data.files);
if (data.files.length > 0 && !currentGitDiffFile) {
selectGitFileDiff(data.files[0].path, data.files[0].is_staged);
} else if (data.files.length === 0) {
if (diffViewer) diffViewer.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">✔ Working tree clean — no uncommitted changes.</div>';
if (diffHdr) diffHdr.textContent = 'Working Tree Clean';
}
} catch (e) {
if (filesList) filesList.innerHTML = `<div style="padding: 12px; color: var(--danger);">Error: ${e}</div>`;
}
}
function renderGitFiles(files) {
const filesList = document.getElementById('git-files-list');
if (!filesList) return;
if (files.length === 0) {
filesList.innerHTML = '<div style="padding: 16px; color: var(--text-dim); text-align: center; font-size: 11px;">No modified or untracked files.</div>';
return;
}
filesList.innerHTML = files.map(f => {
let statusClass = f.is_untracked ? 'untracked' : (f.is_staged ? 'staged' : (f.is_deleted ? 'deleted' : 'modified'));
let statusChar = f.is_untracked ? '?' : (f.is_deleted ? 'D' : (f.is_staged ? '+' : 'M'));
let actionBtn = f.is_staged
? `<button class="btn btn-xs" onclick="event.stopPropagation(); gitUnstageFile('${escapeHtml(f.path)}')" title="Unstage file">−</button>`
: `<button class="btn btn-xs btn-accent" onclick="event.stopPropagation(); gitStageFile('${escapeHtml(f.path)}')" title="Stage file">+</button>`;
return `
<div class="git-file-row ${currentGitDiffFile === f.path ? 'active' : ''}" onclick="selectGitFileDiff('${escapeHtml(f.path)}', ${f.is_staged})">
<span style="display: flex; align-items: center; gap: 6px; overflow: hidden;">
<span class="git-status-char ${statusClass}">${statusChar}</span>
<span style="font-family: var(--font-mono); text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${escapeHtml(f.path)}</span>
</span>
<div style="display: flex; gap: 4px; flex-shrink: 0;">
${actionBtn}
</div>
</div>
`;
}).join('');
}
async function selectGitFileDiff(filePath, isStaged) {
currentGitDiffFile = filePath;
const diffViewer = document.getElementById('git-diff-viewer');
const diffHdr = document.getElementById('git-diff-filename');
const badge = document.getElementById('git-diff-status-badge');
if (diffHdr) diffHdr.textContent = filePath;
if (badge) {
badge.style.display = 'inline-block';
badge.textContent = isStaged ? 'STAGED' : 'UNSTAGED';
badge.className = `badge ${isStaged ? 'badge-success' : 'badge-warning'}`;
}
if (diffViewer) diffViewer.innerHTML = '<div style="color: var(--text-dim);">Loading diff...</div>';
try {
const url = `/api/git/diff?path=${encodeURIComponent(currentGitRepoPath)}&file=${encodeURIComponent(filePath)}&staged=${isStaged}`;
const resp = await fetch(url, { headers: { 'Authorization': `Bearer ${App.token}` } });
if (resp.ok) {
const data = await resp.json();
renderGitDiffOutput(data.diff || 'No diff output.');
}
} catch (e) {
if (diffViewer) diffViewer.textContent = `Error loading diff: ${e}`;
}
}
function renderGitDiffOutput(rawDiff) {
const viewer = document.getElementById('git-diff-viewer');
if (!viewer) return;
const lines = rawDiff.split('\n');
viewer.innerHTML = lines.map(line => {
const escaped = escapeHtml(line);
if (line.startsWith('+') && !line.startsWith('+++')) {
return `<span class="diff-line-add">${escaped}</span>`;
} else if (line.startsWith('-') && !line.startsWith('---')) {
return `<span class="diff-line-del">${escaped}</span>`;
} else if (line.startsWith('@@') || line.startsWith('diff --git')) {
return `<span class="diff-line-hdr">${escaped}</span>`;
}
return `<span>${escaped}</span>`;
}).join('');
}
async function gitStageFile(filePath) {
try {
await fetch('/api/git/stage', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath, files: [filePath] })
});
loadGitStatus(currentGitRepoPath);
} catch (e) {}
}
async function gitUnstageFile(filePath) {
try {
await fetch('/api/git/unstage', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath, files: [filePath] })
});
loadGitStatus(currentGitRepoPath);
} catch (e) {}
}
async function gitStageAll() {
try {
await fetch('/api/git/stage', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath, files: ['.'] })
});
showToast('Staged all changes (+)', 'success');
loadGitStatus(currentGitRepoPath);
} catch (e) {}
}
async function gitUnstageAll() {
try {
await fetch('/api/git/unstage', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath, files: ['.'] })
});
showToast('Unstaged all changes', 'info');
loadGitStatus(currentGitRepoPath);
} catch (e) {}
}
async function gitCommitStaged() {
const msgInput = document.getElementById('git-commit-msg-input');
const msg = msgInput?.value?.trim();
if (!msg) {
showToast('Please enter a commit message', 'warning');
return;
}
try {
const resp = await fetch('/api/git/commit', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath, message: msg })
});
if (resp.ok) {
if (msgInput) msgInput.value = '';
showToast(`Committed: "${msg}"`, 'success');
loadGitStatus(currentGitRepoPath);
} else {
const err = await resp.text();
showToast(`Commit failed: ${err}`, 'error');
}
} catch (e) {
showToast('Failed to commit', 'error');
}
}
async function gitPushCurrentRepo() {
showToast('Pushing commits to remote repository...', 'info');
try {
const resp = await fetch('/api/git/push', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath })
});
if (resp.ok) {
showToast('Pushed commits successfully! 🚀', 'success');
loadGitStatus(currentGitRepoPath);
} else {
const err = await resp.text();
showToast(`Push failed: ${err}`, 'error');
}
} catch (e) {
showToast('Push failed', 'error');
}
}
async function gitPullCurrentRepo() {
showToast('Pulling latest changes from remote...', 'info');
try {
const resp = await fetch('/api/git/pull', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentGitRepoPath })
});
if (resp.ok) {
showToast('Pull complete! 📥', 'success');
loadGitStatus(currentGitRepoPath);
renderAllPanes();
} else {
const err = await resp.text();
showToast(`Pull failed: ${err}`, 'error');
}
} catch (e) {
showToast('Pull failed', 'error');
}
}
function switchGitTab(tabName) {
const statusTab = document.getElementById('git-status-tab-body');
const logTab = document.getElementById('git-log-tab-body');
const statusBtn = document.getElementById('git-tab-status-btn');
const logBtn = document.getElementById('git-tab-log-btn');
if (tabName === 'status') {
if (statusTab) statusTab.style.display = 'flex';
if (logTab) logTab.style.display = 'none';
if (statusBtn) statusBtn.classList.add('active');
if (logBtn) logBtn.classList.remove('active');
} else {
if (statusTab) statusTab.style.display = 'none';
if (logTab) logTab.style.display = 'block';
if (statusBtn) statusBtn.classList.remove('active');
if (logBtn) logBtn.classList.add('active');
loadGitLog(currentGitRepoPath);
}
}
async function loadGitLog(repoPath) {
const tbody = document.getElementById('git-log-tbody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding:16px;">Loading commit history...</td></tr>';
try {
const resp = await fetch(`/api/git/log?path=${encodeURIComponent(repoPath)}&count=40`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const commits = await resp.json();
if (commits.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding:16px;">No commits found in repository.</td></tr>';
return;
}
tbody.innerHTML = commits.map(c => `
<tr>
<td><code style="color:var(--accent); font-weight:700;">${escapeHtml(c.short_hash)}</code></td>
<td style="font-weight:600;">${escapeHtml(c.message)}</td>
<td><span style="font-size:11px; color:var(--text-muted);">${escapeHtml(c.author_name)}</span></td>
<td><span style="font-size:10.5px; color:var(--text-dim);">${escapeHtml(c.date)}</span></td>
</tr>
`).join('');
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="4" style="color:var(--danger); padding:16px;">Error: ${e}</td></tr>`;
}
}
async function loadGitStatusForDocked(paneIndex, repoPath) {
const branchEl = document.getElementById('docked-git-branch');
const fileList = document.getElementById('docked-git-file-list');
try {
const resp = await fetch(`/api/git/status?path=${encodeURIComponent(repoPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
if (branchEl) branchEl.textContent = data.branch || 'HEAD';
if (fileList) {
if (data.files.length === 0) {
fileList.innerHTML = '<div style="padding:10px; color:#22c55e; text-align:center;">✔ Clean working directory</div>';
} else {
fileList.innerHTML = data.files.map(f => `
<div style="display:flex; justify-content:space-between; padding:3px 6px; font-size:11px; font-family:var(--font-mono);">
<span>${escapeHtml(f.path)}</span>
<span style="color:var(--accent); font-weight:700;">${f.is_staged ? '+' : 'M'}</span>
</div>
`).join('');
}
}
}
} catch (e) {}
}
// =========================================================================
// =========================================================================
// 🧩 MULTI-PART FILE SPLITTER & COMBINER ENGINE
// =========================================================================
let splitterCurrentFileSizeBytes = 0;
let combinerDetectedParts = [];
let splitterDragInit = false;
function isSplitterDocked() {
return Array.isArray(App.panes) && App.panes.some(p => p && p.dockedTool === 'splitter');
}
function switchSplitterTab(tab) {
const splitBtn = document.getElementById('btn-splitter-tab-split');
const combineBtn = document.getElementById('btn-splitter-tab-combine');
const splitContent = document.getElementById('splitter-tab-split');
const combineContent = document.getElementById('splitter-tab-combine');
if (tab === 'combine') {
if (splitBtn) splitBtn.classList.remove('active');
if (combineBtn) combineBtn.classList.add('active');
if (splitContent) splitContent.style.display = 'none';
if (combineContent) combineContent.style.display = 'flex';
} else {
if (splitBtn) splitBtn.classList.add('active');
if (combineBtn) combineBtn.classList.remove('active');
if (splitContent) splitContent.style.display = 'flex';
if (combineContent) combineContent.style.display = 'none';
}
if (window.lucide) lucide.createIcons();
}
function initSplitterDragResize() {
if (splitterDragInit) return;
splitterDragInit = true;
const win = document.getElementById('floating-splitter-window');
const header = document.getElementById('splitter-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_splitter_x');
const savedTop = localStorage.getItem('cd_splitter_y');
const savedWidth = localStorage.getItem('cd_splitter_w');
const savedHeight = localStorage.getItem('cd_splitter_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(340, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(380, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
let isResizing = false;
let resizeMode = null;
let startW = 0, startH = 0;
let resizeStartX = 0, resizeStartY = 0;
let resizeStartLeft = 0, resizeStartTop = 0;
function onResizeStart(e, mode) {
if (window.innerWidth <= 1024 || win.classList.contains('maximized')) return;
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeMode = mode;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
startW = win.offsetWidth;
startH = win.offsetHeight;
const rect = win.getBoundingClientRect();
resizeStartLeft = rect.left;
resizeStartTop = rect.top;
bringFloatingWindowToFront(win);
document.body.style.userSelect = 'none';
if (mode === 'corner') document.body.style.cursor = 'nwse-resize';
else if (mode === 'right' || mode === 'left') document.body.style.cursor = 'ew-resize';
else if (mode === 'bottom' || mode === 'top') document.body.style.cursor = 'ns-resize';
}
const cornerH = document.getElementById('splitter-resize-corner');
const rightH = document.getElementById('splitter-resize-right');
const bottomH = document.getElementById('splitter-resize-bottom');
const leftH = document.getElementById('splitter-resize-left');
const topH = document.getElementById('splitter-resize-top');
if (cornerH) cornerH.addEventListener('mousedown', (e) => onResizeStart(e, 'corner'));
if (rightH) rightH.addEventListener('mousedown', (e) => onResizeStart(e, 'right'));
if (bottomH) bottomH.addEventListener('mousedown', (e) => onResizeStart(e, 'bottom'));
if (leftH) leftH.addEventListener('mousedown', (e) => onResizeStart(e, 'left'));
if (topH) topH.addEventListener('mousedown', (e) => onResizeStart(e, 'top'));
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
return;
}
if (isResizing) {
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
const minW = 340;
const minH = 380;
const maxW = window.innerWidth - 20;
const maxH = window.innerHeight - 40;
if (resizeMode === 'corner' || resizeMode === 'right') {
const newW = Math.max(minW, Math.min(maxW, startW + dx));
win.style.width = `${newW}px`;
}
if (resizeMode === 'corner' || resizeMode === 'bottom') {
const newH = Math.max(minH, Math.min(maxH, startH + dy));
win.style.height = `${newH}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, Math.min(maxW, startW - dx));
const newLeft = resizeStartLeft + (startW - newW);
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
if (resizeMode === 'top') {
const newH = Math.max(minH, Math.min(maxH, startH - dy));
const newTop = resizeStartTop + (startH - newH);
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_splitter_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_splitter_y', parseInt(win.style.top, 10));
}
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_splitter_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_splitter_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_splitter_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_splitter_y', parseInt(win.style.top, 10));
}
});
setupSplitterDropzone(win);
}
function setupSplitterDropzone(customWin = null) {
const win = customWin || document.getElementById('floating-splitter-window');
const body = document.getElementById('splitter-body');
const dropzone = document.getElementById('splitter-dropzone');
const combineList = document.getElementById('combine-parts-list');
const targetElements = [win, body, dropzone, combineList].filter(Boolean);
targetElements.forEach(el => {
if (el._splitterDropzoneBound) return;
el._splitterDropzoneBound = true;
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (dropzone) dropzone.classList.add('drag-over');
if (win) win.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
if (!el.contains(e.relatedTarget)) {
if (dropzone) dropzone.classList.remove('drag-over');
if (win) win.classList.remove('drag-over');
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (dropzone) dropzone.classList.remove('drag-over');
if (win) win.classList.remove('drag-over');
let droppedPaths = [];
// 1. Internal Brum pane drag data
const plainText = e.dataTransfer.getData('text/plain');
if (plainText) {
try {
const data = JSON.parse(plainText);
if (data.paths && data.paths.length > 0) {
droppedPaths = data.paths;
}
} catch (_) {
if (plainText.startsWith('/') || plainText.match(/^[a-zA-Z]:\\/)) {
droppedPaths = [plainText.trim()];
}
}
}
// 2. Local OS file drop
if (droppedPaths.length === 0 && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
droppedPaths = Array.from(e.dataTransfer.files).map(f => f.path).filter(Boolean);
}
if (droppedPaths.length === 0) return;
const isCombineTabActive = document.getElementById('splitter-tab-combine')?.style.display !== 'none';
const isPartFiles = droppedPaths.every(p => /\.\d{3}$/.test(p) || /\.part\d+/i.test(p));
if (isCombineTabActive || (droppedPaths.length > 1 && isPartFiles)) {
switchSplitterTab('combine');
const unique = Array.from(new Set([...combinerDetectedParts, ...droppedPaths]));
setCombinerParts(unique);
showToast(`Added ${droppedPaths.length} part file${droppedPaths.length === 1 ? '' : 's'} to combiner`, 'success');
} else {
switchSplitterTab('split');
setSplitterSourceFile(droppedPaths[0]);
const name = droppedPaths[0].split('/').filter(Boolean).pop() || droppedPaths[0];
showToast(`Selected file: ${name}`, 'info');
}
});
});
}
function openFileSplitterModal(filePath = null, sizeBytes = null) {
const win = document.getElementById('floating-splitter-window');
const body = document.getElementById('splitter-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('splitter-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initSplitterDragResize();
switchSplitterTab('split');
// If no file path is provided, try resolving from active pane
if (!filePath) {
const pane = App.panes[App.activePaneIndex];
if (pane && pane.selected && pane.selected.size > 0) {
filePath = Array.from(pane.selected)[0];
const entry = (pane.entries || []).find(e => e.path === filePath);
if (entry && !entry.is_dir) {
sizeBytes = entry.size || 0;
} else if (entry && entry.is_dir) {
filePath = '';
sizeBytes = 0;
}
} else if (pane && Array.isArray(pane.entries) && pane.entries.length > 0) {
const firstFile = pane.entries.find(e => e.name !== '..' && !e.is_dir);
if (firstFile) {
filePath = firstFile.path;
sizeBytes = firstFile.size || 0;
}
}
}
setSplitterSourceFile(filePath, sizeBytes);
setupSplitterDropzone();
}
async function setSplitterSourceFile(filePath, sizeBytes) {
const srcInput = document.getElementById('split-source-path');
const destInput = document.getElementById('split-dest-dir');
const dropzoneName = document.getElementById('splitter-dropzone-filename');
const cleanPath = (filePath || '').trim();
if (srcInput) srcInput.value = cleanPath;
if (dropzoneName) {
if (cleanPath) {
const name = cleanPath.split('/').filter(Boolean).pop() || cleanPath;
dropzoneName.textContent = name;
dropzoneName.title = cleanPath;
} else {
dropzoneName.textContent = 'Drop file here or click to browse...';
dropzoneName.title = '';
}
}
const defaultDest = cleanPath && cleanPath.includes('/')
? (cleanPath.substring(0, cleanPath.lastIndexOf('/')) || '/')
: (App.panes[App.activePaneIndex]?.path || getUserDefaultHomeDir() || '/');
if (destInput) destInput.value = defaultDest;
if (sizeBytes !== undefined && sizeBytes !== null && sizeBytes > 0) {
splitterCurrentFileSizeBytes = sizeBytes;
updateSplitEstimate(sizeBytes);
} else if (cleanPath) {
// Look up in pane entries first
const pane = App.panes[App.activePaneIndex];
const entry = (pane?.entries || []).find(e => e.path === cleanPath);
if (entry && entry.size !== undefined) {
splitterCurrentFileSizeBytes = entry.size;
updateSplitEstimate(entry.size);
} else {
updateSplitEstimate(0);
try {
const resp = await fetch(`/api/fs/stat?path=${encodeURIComponent(cleanPath)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const meta = await resp.json();
if (meta && typeof meta.size === 'number') {
splitterCurrentFileSizeBytes = meta.size;
updateSplitEstimate(meta.size);
}
}
} catch (_) {}
}
} else {
splitterCurrentFileSizeBytes = 0;
updateSplitEstimate(0);
}
}
function handleSplitSourceChange(val) {
setSplitterSourceFile(val);
}
function useActivePaneItemForSplitter() {
const pane = App.panes[App.activePaneIndex];
if (!pane) return;
const selPath = (pane.selected && pane.selected.size > 0)
? Array.from(pane.selected)[0]
: (pane.entries && pane.entries[pane.cursorIndex] ? pane.entries[pane.cursorIndex].path : null);
if (selPath) {
const entry = (pane.entries || []).find(e => e.path === selPath);
if (entry && entry.is_dir) {
showToast('Selected item is a directory. Please select a file to split.', 'warning');
return;
}
setSplitterSourceFile(selPath, entry?.size || 0);
} else {
showToast('No file selected in active pane', 'warning');
}
}
function updateSplitEstimate(sizeBytes) {
if (sizeBytes === undefined) sizeBytes = splitterCurrentFileSizeBytes;
const sizeLbl = document.getElementById('split-source-size-label');
const preset = document.getElementById('split-size-preset')?.value || '100';
const custom = document.getElementById('split-size-custom')?.value || '100';
const chunkMb = preset === 'custom' ? parseInt(custom, 10) : parseInt(preset, 10);
if (!sizeLbl) return;
if (sizeBytes > 0 && chunkMb && chunkMb > 0) {
const chunkBytes = chunkMb * 1024 * 1024;
const count = Math.ceil(sizeBytes / chunkBytes);
sizeLbl.innerHTML = `Size: <strong>${formatBytes(sizeBytes)}</strong> • Estimated: <span style="color: var(--accent); font-weight: 600;">${count} chunk${count === 1 ? '' : 's'}</span> (~${chunkMb} MB each)`;
} else if (sizeBytes > 0) {
sizeLbl.innerHTML = `Size: <strong>${formatBytes(sizeBytes)}</strong>`;
} else {
sizeLbl.textContent = 'No file selected (select a file or drag & drop)';
}
}
function handleSplitPresetChange(val) {
const custom = document.getElementById('split-size-custom');
if (custom) {
custom.style.display = val === 'custom' ? 'block' : 'none';
}
updateSplitEstimate();
}
async function executeFileSplit() {
const srcPath = document.getElementById('split-source-path')?.value?.trim();
const preset = document.getElementById('split-size-preset')?.value;
const custom = document.getElementById('split-size-custom')?.value;
const destDir = document.getElementById('split-dest-dir')?.value?.trim();
const genChk = document.getElementById('split-generate-checksum')?.checked ?? true;
const progBox = document.getElementById('split-progress-box');
const statusTxt = document.getElementById('split-status-text');
const btn = document.getElementById('btn-run-split');
if (!srcPath) {
showToast('Please select or specify a source file to split', 'warning');
return;
}
const chunkMb = preset === 'custom' ? parseInt(custom, 10) : parseInt(preset, 10);
if (!chunkMb || chunkMb < 1) {
showToast('Please enter a valid chunk size (minimum 1 MB)', 'warning');
return;
}
if (progBox) progBox.style.display = 'block';
if (statusTxt) statusTxt.innerHTML = `<i data-lucide="loader" class="spinner" style="width: 14px; height: 14px;"></i> Splitting file into ${chunkMb} MB chunks...`;
if (btn) btn.disabled = true;
if (window.lucide) lucide.createIcons({ root: progBox });
try {
const resp = await fetch('/api/tools/split', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: srcPath,
chunk_size_mb: chunkMb,
dest_dir: destDir || null,
generate_checksum: genChk
})
});
if (resp.ok) {
const res = await resp.json();
if (!isSplitterDocked()) {
closeFloatingSplitter();
}
showToast(`Successfully split into ${res.chunk_count} parts! SHA-256: ${res.sha256.substring(0, 10)}...`, 'success');
refreshAllPanes();
} else {
const err = await resp.text();
showToast(`Split failed: ${sanitizeCredentials(err)}`, 'error');
}
} catch (e) {
showToast(`Error splitting file: ${sanitizeCredentials(String(e))}`, 'error');
} finally {
if (progBox) progBox.style.display = 'none';
if (btn) btn.disabled = false;
}
}
function openFileCombinerModal(partsList = null) {
const win = document.getElementById('floating-splitter-window');
const body = document.getElementById('splitter-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('splitter-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initSplitterDragResize();
switchSplitterTab('combine');
let parts = Array.isArray(partsList) ? partsList : [];
if (parts.length === 0) {
const pane = App.panes[App.activePaneIndex];
if (pane && pane.selected && pane.selected.size > 0) {
parts = Array.from(pane.selected).filter(p => /\.\d{3}$/.test(p) || /\.part\d+/i.test(p));
}
if (parts.length === 0 && pane && pane.entries) {
parts = pane.entries
.filter(e => !e.is_dir && (/\.\d{3}$/.test(e.name) || /\.part\d+/i.test(e.name)))
.map(e => e.path);
}
}
setCombinerParts(parts);
setupSplitterDropzone();
}
function setCombinerParts(partsList) {
combinerDetectedParts = partsList || [];
combinerDetectedParts.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
const partsBox = document.getElementById('combine-parts-list');
const destInput = document.getElementById('combine-dest-path');
const expSha = document.getElementById('combine-expected-sha');
const resultBox = document.getElementById('combine-result-box');
if (resultBox) resultBox.style.display = 'none';
if (partsBox) {
if (combinerDetectedParts.length === 0) {
partsBox.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 18px;">No part files detected (.001, .part1...). Drag files here, select in pane, or click Scan.</div>';
} else {
partsBox.innerHTML = combinerDetectedParts.map((p, idx) => {
const basename = p.split('/').filter(Boolean).pop() || p;
return `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 3px 6px; background: rgba(255,255,255,0.03); border-radius: 4px; margin-bottom: 2px;">
<span title="${escapeHtml(p)}" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 360px;">
<span style="color: var(--accent); font-weight: 700; margin-right: 4px;">#${idx + 1}</span> ${escapeHtml(basename)}
</span>
<button type="button" class="btn btn-icon btn-xs" style="width: 20px; height: 20px; padding: 0;" onclick="removeCombinePart(${idx})" title="Remove"><i data-lucide="x" style="width: 10px; height: 10px;"></i></button>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: partsBox });
}
}
if (combinerDetectedParts.length > 0 && destInput) {
const firstPart = combinerDetectedParts[0];
const cleanedDest = firstPart.replace(/\.\d{3}$/, '').replace(/\.part\d+.*$/, '');
destInput.value = cleanedDest;
// Check if .sha256 manifest exists in active pane
const pane = App.panes[App.activePaneIndex];
if (pane && pane.entries && expSha) {
const shaFile = pane.entries.find(e => e.name.endsWith('.sha256') && (cleanedDest.endsWith(e.name.replace('.sha256', '')) || firstPart.endsWith(e.name.replace('.sha256', ''))));
if (shaFile) {
fetch(`/api/fs/read?path=${encodeURIComponent(shaFile.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
}).then(r => r.text()).then(txt => {
const match = txt.match(/[a-fA-F0-9]{64}/);
if (match && expSha) {
expSha.value = match[0];
}
}).catch(() => {});
}
}
}
}
function removeCombinePart(index) {
if (index >= 0 && index < combinerDetectedParts.length) {
combinerDetectedParts.splice(index, 1);
setCombinerParts(combinerDetectedParts);
}
}
function clearCombineParts() {
setCombinerParts([]);
const destInput = document.getElementById('combine-dest-path');
const expSha = document.getElementById('combine-expected-sha');
if (destInput) destInput.value = '';
if (expSha) expSha.value = '';
}
function scanPaneForCombineParts() {
const pane = App.panes[App.activePaneIndex];
if (!pane || !pane.entries) {
showToast('Active pane is empty', 'info');
return;
}
let parts = [];
if (pane.selected && pane.selected.size > 0) {
parts = Array.from(pane.selected).filter(p => /\.\d{3}$/.test(p) || /\.part\d+/i.test(p));
}
if (parts.length === 0) {
parts = pane.entries
.filter(e => !e.is_dir && (/\.\d{3}$/.test(e.name) || /\.part\d+/i.test(e.name)))
.map(e => e.path);
}
if (parts.length === 0) {
showToast('No multi-part files (.001, .part1...) found in active pane', 'info');
return;
}
setCombinerParts(parts);
showToast(`Detected ${parts.length} part files in active pane`, 'success');
}
async function executeFileCombine() {
const destInput = document.getElementById('combine-dest-path');
const expSha = document.getElementById('combine-expected-sha');
const btn = document.getElementById('btn-run-combine');
const resultBox = document.getElementById('combine-result-box');
const destPath = destInput?.value?.trim();
if (!destPath) {
showToast('Please enter output destination path', 'warning');
return;
}
if (combinerDetectedParts.length === 0) {
showToast('No part files selected to combine', 'warning');
return;
}
if (btn) btn.disabled = true;
if (resultBox) {
resultBox.style.display = 'block';
resultBox.innerHTML = '<span style="color: var(--accent);"><i data-lucide="loader" class="spinner"></i> Combining and verifying file parts...</span>';
if (window.lucide) lucide.createIcons({ root: resultBox });
}
try {
const resp = await fetch('/api/tools/combine', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
parts: combinerDetectedParts,
dest_path: destPath,
expected_sha256: expSha?.value?.trim() || null
})
});
if (resp.ok) {
const res = await resp.json();
const verifyMsg = res.is_verified ? '✔ Verified SHA-256 Checksum!' : (expSha?.value?.trim() ? '⚠️ Checksum mismatch' : `SHA-256: ${res.sha256.substring(0, 12)}...`);
showToast(`Successfully combined ${res.parts_joined} parts (${formatBytes(res.total_bytes_written)})!`, 'success');
if (resultBox) {
resultBox.innerHTML = `<span style="color: #22c55e; font-weight: 700;">✓ Combined ${res.parts_joined} parts successfully (${formatBytes(res.total_bytes_written)})</span><br><span style="font-family: var(--font-mono); font-size: 10.5px; opacity: 0.85;">${verifyMsg}</span>`;
}
setTimeout(() => {
if (!isSplitterDocked()) {
closeFloatingSplitter();
}
}, 1200);
refreshAllPanes();
} else {
const err = await resp.text();
showToast(`Combine failed: ${sanitizeCredentials(err)}`, 'error');
if (resultBox) {
resultBox.innerHTML = `<span style="color: var(--danger);">Failed: ${escapeHtml(sanitizeCredentials(err))}</span>`;
}
}
} catch (e) {
showToast(`Error combining files: ${sanitizeCredentials(String(e))}`, 'error');
if (resultBox) {
resultBox.innerHTML = `<span style="color: var(--danger);">Error: ${escapeHtml(String(e))}</span>`;
}
} finally {
if (btn) btn.disabled = false;
}
}
function closeFloatingSplitter() {
const win = document.getElementById('floating-splitter-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('splitter-pill');
if (pill) pill.style.display = 'none';
}
function minimizeFloatingSplitter() {
const win = document.getElementById('floating-splitter-window');
const pill = document.getElementById('splitter-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingSplitter() {
const isCombineTabActive = document.getElementById('splitter-tab-combine')?.style.display !== 'none';
if (isCombineTabActive) {
openFileCombinerModal(combinerDetectedParts);
} else {
openFileSplitterModal();
}
}
function toggleMaximizeSplitter() {
const win = document.getElementById('floating-splitter-window');
if (win) win.classList.toggle('maximized');
}
function dockSplitterToActivePane() {
closeFloatingSplitter();
App.panes[App.activePaneIndex].dockedTool = 'splitter';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'splitter');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function mountDockedSplitter(paneIndex) {
const host = document.getElementById(`docked-splitter-host-${paneIndex}`);
const floatingBody = document.getElementById('splitter-body');
if (host && floatingBody) {
host.appendChild(floatingBody);
setupSplitterDropzone();
if (window.lucide) lucide.createIcons();
}
}
// ============================================================================
// 🌲 FOLDER HIERARCHY TREE VIEW MODULE
// ============================================================================
App.treeViewOpen = localStorage.getItem('cd_tree_view_open') === 'true';
App.treeExpandedPaths = new Set(JSON.parse(localStorage.getItem('cd_tree_expanded') || '[]'));
App.treeSidebarWidth = parseInt(localStorage.getItem('cd_tree_width') || '260');
function toggleFolderTree(forceState) {
if (forceState !== undefined) {
App.treeViewOpen = !!forceState;
} else {
App.treeViewOpen = !App.treeViewOpen;
}
const sidebar = document.getElementById('tree-view-sidebar');
const resizer = document.getElementById('tree-resizer-handle');
const btn = document.getElementById('btn-toggle-tree');
if (sidebar && resizer) {
if (App.treeViewOpen) {
sidebar.style.display = 'flex';
sidebar.style.width = `${App.treeSidebarWidth}px`;
resizer.style.display = 'block';
if (btn) btn.classList.add('active');
renderFolderTreeRoot();
} else {
sidebar.style.display = 'none';
resizer.style.display = 'none';
if (btn) btn.classList.remove('active');
}
}
localStorage.setItem('cd_tree_view_open', App.treeViewOpen ? 'true' : 'false');
if (window.lucide) lucide.createIcons();
}
function initFolderTree() {
const sidebar = document.getElementById('tree-view-sidebar');
const resizer = document.getElementById('tree-resizer-handle');
const btn = document.getElementById('btn-toggle-tree');
if (App.treeSidebarWidth) {
document.documentElement.style.setProperty('--tree-sidebar-width', `${App.treeSidebarWidth}px`);
}
if (App.treeViewOpen && sidebar && resizer) {
sidebar.style.display = 'flex';
resizer.style.display = 'block';
if (btn) btn.classList.add('active');
renderFolderTreeRoot();
}
}
async function renderFolderTreeRoot() {
const rootContainer = document.getElementById('tree-view-root');
if (!rootContainer) return;
rootContainer.innerHTML = '<div style="padding: 12px; color: var(--text-muted); font-size: 11px;"><i data-lucide="loader" class="spin"></i> Loading directory tree...</div>';
if (window.lucide) lucide.createIcons();
try {
const roots = await fetchStorageRoots();
rootContainer.innerHTML = '';
if (!roots || roots.length === 0) {
const userHome = getUserDefaultHomeDir();
const fallbackRoot = (userHome && userHome !== '/')
? { id: 'home', name: 'Home', path: userHome, is_dir: true, is_home: true }
: { id: 'root', name: 'Root Filesystem (/)', path: '/', is_dir: true };
buildTreeNode(rootContainer, fallbackRoot, 0);
return;
}
// Separate Windows drives and unix/storage roots
const drives = roots.filter(r => r.path.match(/^[a-zA-Z]:[\\/]/));
const storageRoots = roots.filter(r => !r.path.match(/^[a-zA-Z]:[\\/]/));
if (drives.length > 0) {
const groupTitle = document.createElement('div');
groupTitle.className = 'breadcrumb-popover-header';
groupTitle.style.padding = '4px 6px 2px';
groupTitle.textContent = 'Drives';
rootContainer.appendChild(groupTitle);
for (const d of drives) {
buildTreeNode(rootContainer, {
name: `${d.path} (${d.name.replace(/^Local Disk\s*\(/i, '').replace(/\)$/, '')})`,
path: d.path,
is_dir: true,
is_drive: true
}, 0);
}
}
if (storageRoots.length > 0) {
if (drives.length > 0) {
const groupTitle = document.createElement('div');
groupTitle.className = 'breadcrumb-popover-header';
groupTitle.style.padding = '8px 6px 2px';
groupTitle.textContent = 'Storage Roots';
rootContainer.appendChild(groupTitle);
}
for (const r of storageRoots) {
buildTreeNode(rootContainer, {
name: r.name,
path: r.path,
is_dir: true,
is_home: r.id === 'home'
}, 0);
}
}
// Sync active node highlight
const activePath = App.panes[App.activePaneIndex]?.path;
if (activePath) {
syncTreeActiveNode(activePath);
}
} catch (err) {
rootContainer.innerHTML = '<div style="padding: 12px; color: var(--danger); font-size: 11px;">Failed to load folder tree</div>';
}
}
function buildTreeNode(parentContainer, nodeData, depth = 0) {
const nodeWrapper = document.createElement('div');
nodeWrapper.className = 'tree-node-wrapper';
nodeWrapper.dataset.path = nodeData.path;
const row = document.createElement('div');
row.className = 'tree-node-row';
row.dataset.path = nodeData.path;
const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
toggle.innerHTML = '▶';
const icon = document.createElement('span');
icon.className = 'tree-icon';
if (nodeData.is_drive) {
icon.textContent = '🪟';
} else if (nodeData.is_home) {
icon.textContent = '🏠';
} else {
icon.textContent = '📁';
}
const label = document.createElement('span');
label.className = 'tree-label';
label.textContent = nodeData.name;
label.title = `${nodeData.name} (${nodeData.path})`;
row.appendChild(toggle);
row.appendChild(icon);
row.appendChild(label);
nodeWrapper.appendChild(row);
const childrenContainer = document.createElement('div');
childrenContainer.className = 'tree-children';
childrenContainer.style.display = 'none';
nodeWrapper.appendChild(childrenContainer);
let isLoaded = false;
const expandNode = async () => {
toggle.classList.add('expanded');
toggle.innerHTML = '▼';
icon.textContent = nodeData.is_drive ? '🪟' : (nodeData.is_home ? '🏠' : '📂');
childrenContainer.style.display = 'flex';
App.treeExpandedPaths.add(nodeData.path);
saveTreeExpandedState();
if (!isLoaded) {
childrenContainer.innerHTML = '<div style="padding: 4px 6px; font-size: 10.5px; color: var(--text-muted);">Loading...</div>';
try {
const authUrl = resolveAuthUri(nodeData.path);
const res = await fetch(`/api/fs/list?path=${encodeURIComponent(authUrl)}&show_hidden=false`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const data = await res.json();
const subdirs = (data.entries || []).filter(e => e.is_dir);
childrenContainer.innerHTML = '';
if (subdirs.length === 0) {
childrenContainer.innerHTML = '<div style="padding: 2px 6px; font-size: 10px; color: var(--text-dim); font-style: italic;">(Empty)</div>';
} else {
subdirs.sort((a, b) => a.name.localeCompare(b.name)).forEach(child => {
buildTreeNode(childrenContainer, {
name: child.name,
path: child.path,
is_dir: true
}, depth + 1);
});
}
isLoaded = true;
} else {
childrenContainer.innerHTML = '<div style="padding: 2px 6px; font-size: 10px; color: var(--danger);">(Access denied)</div>';
}
} catch (err) {
childrenContainer.innerHTML = '<div style="padding: 2px 6px; font-size: 10px; color: var(--danger);">(Error)</div>';
}
}
};
const collapseNode = () => {
toggle.classList.remove('expanded');
toggle.innerHTML = '▶';
icon.textContent = nodeData.is_drive ? '🪟' : (nodeData.is_home ? '🏠' : '📁');
childrenContainer.style.display = 'none';
App.treeExpandedPaths.delete(nodeData.path);
saveTreeExpandedState();
};
// Toggle arrow click
toggle.onclick = (e) => {
e.stopPropagation();
if (childrenContainer.style.display === 'none') {
expandNode();
} else {
collapseNode();
}
};
// Label click: navigate active pane
row.onclick = (e) => {
e.stopPropagation();
loadPaneDirectory(App.activePaneIndex, nodeData.path);
syncTreeActiveNode(nodeData.path);
};
// Auto-expand if saved in state
if (App.treeExpandedPaths.has(nodeData.path)) {
expandNode();
}
parentContainer.appendChild(nodeWrapper);
}
function saveTreeExpandedState() {
try {
localStorage.setItem('cd_tree_expanded', JSON.stringify(Array.from(App.treeExpandedPaths).slice(0, 100)));
} catch (_) {}
}
function syncTreeActiveNode(targetPath) {
if (!targetPath) return;
const cleanTarget = targetPath.toLowerCase().replace(/[\/\\]+$/, '');
document.querySelectorAll('.tree-node-row').forEach(row => {
const rowPath = (row.dataset.path || '').toLowerCase().replace(/[\/\\]+$/, '');
if (rowPath === cleanTarget) {
row.classList.add('active');
} else {
row.classList.remove('active');
}
});
}
function refreshFolderTree() {
renderFolderTreeRoot();
}
function initTreeResizer() {
const resizer = document.getElementById('tree-resizer-handle');
const sidebar = document.getElementById('tree-view-sidebar');
if (!resizer || !sidebar || resizer._resizerAttached) return;
resizer._resizerAttached = true;
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
resizer.classList.add('resizing');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = Math.max(180, Math.min(600, e.clientX));
App.treeSidebarWidth = newWidth;
sidebar.style.width = `${newWidth}px`;
document.documentElement.style.setProperty('--tree-sidebar-width', `${newWidth}px`);
});
window.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('resizing');
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem('cd_tree_width', App.treeSidebarWidth.toString());
}
});
}
// ---------------- TAB BAR MOUSE WHEEL SCROLLING ----------------
function setupTabBarMouseWheel() {
document.querySelectorAll('.pane-tabs').forEach(el => {
if (!el._wheelAttached) {
el._wheelAttached = true;
el.addEventListener('wheel', (e) => {
if (e.deltaY !== 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
}, { passive: false });
}
});
}
// ---------------- PURE-RUST PDF POWER STUDIO ----------------
let currentPdfMergeList = [];
let currentPdfOrganizerPages = []; // [{ page_num: 1, rotation: 0 }]
let activePdfTab = 'merge';
let pdfDragInit = false;
function isPdfDocked() {
return Array.isArray(App.panes) && App.panes.some(p => p && p.dockedTool === 'pdf');
}
function initPdfDragResize() {
if (pdfDragInit) return;
pdfDragInit = true;
const win = document.getElementById('floating-pdf-window');
const header = document.getElementById('pdf-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_pdf_x');
const savedTop = localStorage.getItem('cd_pdf_y');
const savedWidth = localStorage.getItem('cd_pdf_w');
const savedHeight = localStorage.getItem('cd_pdf_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(360, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(400, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
let isResizing = false;
let resizeMode = null;
let startW = 0, startH = 0;
let resizeStartX = 0, resizeStartY = 0;
let resizeStartLeft = 0, resizeStartTop = 0;
function onResizeStart(e, mode) {
if (window.innerWidth <= 1024 || win.classList.contains('maximized')) return;
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeMode = mode;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
startW = win.offsetWidth;
startH = win.offsetHeight;
const rect = win.getBoundingClientRect();
resizeStartLeft = rect.left;
resizeStartTop = rect.top;
bringFloatingWindowToFront(win);
document.body.style.userSelect = 'none';
if (mode === 'corner') document.body.style.cursor = 'nwse-resize';
else if (mode === 'right' || mode === 'left') document.body.style.cursor = 'ew-resize';
else if (mode === 'bottom' || mode === 'top') document.body.style.cursor = 'ns-resize';
}
const cornerH = document.getElementById('pdf-resize-corner');
const rightH = document.getElementById('pdf-resize-right');
const bottomH = document.getElementById('pdf-resize-bottom');
const leftH = document.getElementById('pdf-resize-left');
const topH = document.getElementById('pdf-resize-top');
if (cornerH) cornerH.addEventListener('mousedown', (e) => onResizeStart(e, 'corner'));
if (rightH) rightH.addEventListener('mousedown', (e) => onResizeStart(e, 'right'));
if (bottomH) bottomH.addEventListener('mousedown', (e) => onResizeStart(e, 'bottom'));
if (leftH) leftH.addEventListener('mousedown', (e) => onResizeStart(e, 'left'));
if (topH) topH.addEventListener('mousedown', (e) => onResizeStart(e, 'top'));
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
return;
}
if (isResizing) {
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
const minW = 360;
const minH = 400;
const maxW = window.innerWidth - 20;
const maxH = window.innerHeight - 40;
if (resizeMode === 'corner' || resizeMode === 'right') {
const newW = Math.max(minW, Math.min(maxW, startW + dx));
win.style.width = `${newW}px`;
}
if (resizeMode === 'corner' || resizeMode === 'bottom') {
const newH = Math.max(minH, Math.min(maxH, startH + dy));
win.style.height = `${newH}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, Math.min(maxW, startW - dx));
const newLeft = resizeStartLeft + (startW - newW);
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
if (resizeMode === 'top') {
const newH = Math.max(minH, Math.min(maxH, startH - dy));
const newTop = resizeStartTop + (startH - newH);
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_pdf_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_pdf_y', parseInt(win.style.top, 10));
}
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_pdf_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_pdf_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_pdf_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_pdf_y', parseInt(win.style.top, 10));
}
});
setupPdfDropzone(win);
}
function setupPdfDropzone(customWin = null) {
const win = customWin || document.getElementById('floating-pdf-window');
const body = document.getElementById('pdf-body');
const mergeList = document.getElementById('pdf-merge-file-list');
const splitDropzone = document.getElementById('pdf-split-dropzone');
const reorderDropzone = document.getElementById('pdf-reorder-dropzone');
const organizerGrid = document.getElementById('pdf-page-organizer-grid');
const targetElements = [win, body, mergeList, splitDropzone, reorderDropzone, organizerGrid].filter(Boolean);
targetElements.forEach(el => {
if (el._pdfDropzoneBound) return;
el._pdfDropzoneBound = true;
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (win) win.classList.add('drag-over');
if (mergeList) mergeList.classList.add('drag-over');
if (splitDropzone) splitDropzone.classList.add('drag-over');
if (reorderDropzone) reorderDropzone.classList.add('drag-over');
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
if (!el.contains(e.relatedTarget)) {
if (win) win.classList.remove('drag-over');
if (mergeList) mergeList.classList.remove('drag-over');
if (splitDropzone) splitDropzone.classList.remove('drag-over');
if (reorderDropzone) reorderDropzone.classList.remove('drag-over');
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (win) win.classList.remove('drag-over');
if (mergeList) mergeList.classList.remove('drag-over');
if (splitDropzone) splitDropzone.classList.remove('drag-over');
if (reorderDropzone) reorderDropzone.classList.remove('drag-over');
let droppedPaths = [];
// 1. Internal Brum pane drag data
const plainText = e.dataTransfer.getData('text/plain');
if (plainText) {
try {
const data = JSON.parse(plainText);
if (data.paths && data.paths.length > 0) {
droppedPaths = data.paths;
}
} catch (_) {
if (plainText.startsWith('/') || plainText.match(/^[a-zA-Z]:\\/)) {
droppedPaths = [plainText.trim()];
}
}
}
// 2. Local OS file drop
if (droppedPaths.length === 0 && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
droppedPaths = Array.from(e.dataTransfer.files).map(f => f.path).filter(Boolean);
}
if (droppedPaths.length === 0) return;
const pdfPaths = droppedPaths.filter(p => isPdfExtension(p));
if (pdfPaths.length === 0) {
showToast('Please drop PDF files (.pdf)', 'warning');
return;
}
const isMergeActive = activePdfTab === 'merge';
const isSplitActive = activePdfTab === 'split';
const isReorderActive = activePdfTab === 'reorder';
if (pdfPaths.length > 1 || isMergeActive) {
switchPdfTab('merge');
pdfPaths.forEach(p => {
if (!currentPdfMergeList.includes(p)) currentPdfMergeList.push(p);
});
renderPdfMergeList();
const destEl = document.getElementById('pdf-merge-dest');
if (destEl && !destEl.value && pdfPaths[0]) {
const parentDir = pdfPaths[0].substring(0, pdfPaths[0].lastIndexOf('/')) || '/';
destEl.value = `${parentDir}/merged_document.pdf`;
}
showToast(`Added ${pdfPaths.length} PDF${pdfPaths.length === 1 ? '' : 's'} to Merge list`, 'success');
} else if (isSplitActive) {
const srcEl = document.getElementById('pdf-split-source');
if (srcEl) srcEl.value = pdfPaths[0];
fetchPdfSplitInfo(pdfPaths[0]);
const dirEl = document.getElementById('pdf-split-dest-dir');
if (dirEl && !dirEl.value) {
dirEl.value = pdfPaths[0].substring(0, pdfPaths[0].lastIndexOf('/')) || '/';
}
const name = pdfPaths[0].split('/').filter(Boolean).pop() || pdfPaths[0];
showToast(`Selected PDF to split: ${name}`, 'info');
} else if (isReorderActive) {
const reSrc = document.getElementById('pdf-reorder-source');
if (reSrc) reSrc.value = pdfPaths[0];
loadPdfPagesOrganizer(pdfPaths[0]);
const name = pdfPaths[0].split('/').filter(Boolean).pop() || pdfPaths[0];
showToast(`Loaded PDF for organizer: ${name}`, 'info');
} else {
switchPdfTab('merge');
pdfPaths.forEach(p => {
if (!currentPdfMergeList.includes(p)) currentPdfMergeList.push(p);
});
renderPdfMergeList();
}
});
});
}
function openPdfToolModal(initialPdf = null, tab = 'merge') {
const win = document.getElementById('floating-pdf-window');
const body = document.getElementById('pdf-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('pdf-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initPdfDragResize();
const pane = App.panes[App.activePaneIndex];
if (initialPdf) {
if (tab === 'split') {
const srcEl = document.getElementById('pdf-split-source');
if (srcEl) {
srcEl.value = initialPdf;
fetchPdfSplitInfo(initialPdf);
}
const dirEl = document.getElementById('pdf-split-dest-dir');
if (dirEl && pane) dirEl.value = pane.path;
} else if (tab === 'reorder') {
const reSrc = document.getElementById('pdf-reorder-source');
if (reSrc) {
reSrc.value = initialPdf;
loadPdfPagesOrganizer(initialPdf);
}
} else {
currentPdfMergeList = [initialPdf];
renderPdfMergeList();
const destEl = document.getElementById('pdf-merge-dest');
if (destEl && pane) destEl.value = `${pane.path.replace(/\/?$/, '')}/merged_document.pdf`;
}
} else {
const selectedPdfs = (pane && pane.selected) ? Array.from(pane.selected).filter(p => isPdfExtension(p)) : [];
if (selectedPdfs.length > 0) {
currentPdfMergeList = selectedPdfs;
renderPdfMergeList();
const destEl = document.getElementById('pdf-merge-dest');
if (destEl && pane) destEl.value = `${pane.path.replace(/\/?$/, '')}/merged_document.pdf`;
}
}
switchPdfTab(tab);
setupPdfDropzone();
}
function switchPdfTab(tab) {
activePdfTab = tab;
['merge', 'split', 'reorder'].forEach(t => {
const btn = document.getElementById(`btn-pdf-tab-${t}`);
const content = document.getElementById(`pdf-tab-${t}`);
if (btn) btn.classList.toggle('active', t === tab);
if (content) content.style.display = (t === tab) ? 'flex' : 'none';
});
if (window.lucide) lucide.createIcons();
}
function renderPdfMergeList() {
const box = document.getElementById('pdf-merge-file-list');
if (!box) return;
if (currentPdfMergeList.length === 0) {
box.innerHTML = `<div style="color: var(--text-muted); text-align: center; padding: 24px;">No PDF files added. Drag PDFs here or click Add From Pane.</div>`;
return;
}
box.innerHTML = currentPdfMergeList.map((p, idx) => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; background: var(--bg-header); border: 1px solid var(--border); border-radius: 4px;" data-idx="${idx}">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden;">
<span style="color: var(--text-muted); font-size: 10px; font-family: var(--font-mono);">${idx + 1}.</span>
<i data-lucide="file-text" style="width: 13px; color: #ef4444;"></i>
<span style="font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${escapeHtml(p)}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-icon btn-sm" onclick="movePdfMergeItem(${idx}, -1)" ${idx === 0 ? 'disabled' : ''} title="Move Up"><i data-lucide="arrow-up" style="width:11px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="movePdfMergeItem(${idx}, 1)" ${idx === currentPdfMergeList.length - 1 ? 'disabled' : ''} title="Move Down"><i data-lucide="arrow-down" style="width:11px;"></i></button>
<button class="btn btn-icon btn-sm" onclick="removePdfMergeItem(${idx})" title="Remove"><i data-lucide="x" style="width:11px; color: var(--danger);"></i></button>
</div>
</div>
`).join('');
if (window.lucide) lucide.createIcons();
}
function movePdfMergeItem(idx, delta) {
const target = idx + delta;
if (target < 0 || target >= currentPdfMergeList.length) return;
const item = currentPdfMergeList.splice(idx, 1)[0];
currentPdfMergeList.splice(target, 0, item);
renderPdfMergeList();
}
function removePdfMergeItem(idx) {
currentPdfMergeList.splice(idx, 1);
renderPdfMergeList();
}
function clearPdfMergeList() {
currentPdfMergeList = [];
renderPdfMergeList();
const destEl = document.getElementById('pdf-merge-dest');
if (destEl) destEl.value = '';
}
function addActivePanePdfFiles() {
const pane = App.panes[App.activePaneIndex];
const selectedPdfs = Array.from(pane.selected).filter(p => isPdfExtension(p));
if (selectedPdfs.length === 0) {
const item = pane.entries[pane.cursorIndex];
if (item && isPdfExtension(item.path)) {
selectedPdfs.push(item.path);
}
}
selectedPdfs.forEach(p => {
if (!currentPdfMergeList.includes(p)) {
currentPdfMergeList.push(p);
}
});
renderPdfMergeList();
if (currentPdfMergeList.length > 0) {
const destEl = document.getElementById('pdf-merge-dest');
if (destEl && !destEl.value) {
destEl.value = `${pane.path.replace(/\/?$/, '')}/merged_document.pdf`;
}
}
}
function useActivePaneSelectedPdf(tab) {
const pane = App.panes[App.activePaneIndex];
const firstPdf = Array.from(pane.selected).find(p => isPdfExtension(p)) ||
(pane.entries[pane.cursorIndex] && isPdfExtension(pane.entries[pane.cursorIndex].path) ? pane.entries[pane.cursorIndex].path : null);
if (!firstPdf) {
showToast('Please select a PDF file in the active pane', 'warning');
return;
}
if (tab === 'split') {
const srcEl = document.getElementById('pdf-split-source');
if (srcEl) {
srcEl.value = firstPdf;
fetchPdfSplitInfo(firstPdf);
}
const dirEl = document.getElementById('pdf-split-dest-dir');
if (dirEl) dirEl.value = pane.path;
} else if (tab === 'reorder') {
const reSrc = document.getElementById('pdf-reorder-source');
if (reSrc) {
reSrc.value = firstPdf;
loadPdfPagesOrganizer(firstPdf);
}
}
}
function openPdfSplitFilePicker() {
openUniversalFilePicker({
title: 'Select PDF to Split',
currentPath: document.getElementById('pdf-split-source')?.value || App.panes[App.activePaneIndex]?.path,
filter: (p) => isPdfExtension(p),
onSelect: (filePath) => {
const srcEl = document.getElementById('pdf-split-source');
if (srcEl) srcEl.value = filePath;
fetchPdfSplitInfo(filePath);
const win = document.getElementById('floating-pdf-window');
if (win && win.style.display !== 'none') bringFloatingWindowToFront(win);
}
});
}
function openPdfReorderFilePicker() {
openUniversalFilePicker({
title: 'Select PDF to Reorder',
currentPath: document.getElementById('pdf-reorder-source')?.value || App.panes[App.activePaneIndex]?.path,
filter: (p) => isPdfExtension(p),
onSelect: (filePath) => {
const srcEl = document.getElementById('pdf-reorder-source');
if (srcEl) srcEl.value = filePath;
loadPdfPagesOrganizer(filePath);
const win = document.getElementById('floating-pdf-window');
if (win && win.style.display !== 'none') bringFloatingWindowToFront(win);
}
});
}
async function fetchPdfSplitInfo(pdfPath) {
const badge = document.getElementById('pdf-split-info-badge');
const dropzoneName = document.getElementById('pdf-split-dropzone-filename');
if (dropzoneName && pdfPath) {
const name = pdfPath.split('/').filter(Boolean).pop() || pdfPath;
dropzoneName.textContent = name;
dropzoneName.title = pdfPath;
}
if (!badge || !pdfPath) return;
try {
const resp = await fetch(`/api/tools/pdf/info?path=${encodeURIComponent(resolveAuthUri(pdfPath))}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const info = await resp.json();
badge.style.display = 'block';
badge.textContent = `📄 Total Pages: ${info.page_count} | Size: ${formatBytes(info.file_size_bytes)} | PDF v${info.version}`;
const rangesVal = document.getElementById('pdf-split-ranges-val');
if (rangesVal && info.page_count > 1) {
rangesVal.value = `1-${Math.min(2, info.page_count)}, ${Math.min(3, info.page_count)}-${info.page_count}`;
}
} else {
badge.style.display = 'none';
}
} catch (e) {
badge.style.display = 'none';
}
}
function togglePdfSplitModeUI(mode) {
const rangesGroup = document.getElementById('pdf-split-ranges-group');
const chunkGroup = document.getElementById('pdf-split-chunk-group');
if (rangesGroup) rangesGroup.style.display = mode === 'ranges' ? 'block' : 'none';
if (chunkGroup) chunkGroup.style.display = mode === 'page_count' ? 'block' : 'none';
}
async function loadPdfPagesOrganizer(pdfPath) {
const grid = document.getElementById('pdf-page-organizer-grid');
const countEl = document.getElementById('pdf-reorder-page-count');
const destEl = document.getElementById('pdf-reorder-dest');
const dropzoneName = document.getElementById('pdf-reorder-dropzone-filename');
if (dropzoneName && pdfPath) {
const name = pdfPath.split('/').filter(Boolean).pop() || pdfPath;
dropzoneName.textContent = name;
dropzoneName.title = pdfPath;
}
if (!grid || !pdfPath) return;
grid.innerHTML = `<div style="grid-column: 1 / -1; color: var(--text-muted); text-align: center; padding: 24px;">Loading document pages...</div>`;
try {
const resp = await fetch(`/api/tools/pdf/info?path=${encodeURIComponent(resolveAuthUri(pdfPath))}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const info = await resp.json();
currentPdfOrganizerPages = [];
for (let i = 1; i <= info.page_count; i++) {
currentPdfOrganizerPages.push({ page_num: i, rotation: 0 });
}
if (countEl) countEl.textContent = `${info.page_count} pages`;
if (destEl && !destEl.value) {
destEl.value = pdfPath.replace(/\.pdf$/i, '_reordered.pdf');
}
renderPdfPageOrganizerGrid();
} else {
grid.innerHTML = `<div style="grid-column: 1 / -1; color: var(--danger); text-align: center; padding: 24px;">Failed to read PDF document</div>`;
}
} catch (e) {
grid.innerHTML = `<div style="grid-column: 1 / -1; color: var(--danger); text-align: center; padding: 24px;">Error reading PDF: ${e}</div>`;
}
}
function renderPdfPageOrganizerGrid() {
const grid = document.getElementById('pdf-page-organizer-grid');
const countEl = document.getElementById('pdf-reorder-page-count');
if (!grid) return;
if (countEl) countEl.textContent = `${currentPdfOrganizerPages.length} pages`;
if (currentPdfOrganizerPages.length === 0) {
grid.innerHTML = `<div style="grid-column: 1 / -1; color: var(--text-muted); text-align: center; padding: 24px;">All pages removed. Re-select document to reset.</div>`;
return;
}
grid.innerHTML = currentPdfOrganizerPages.map((p, idx) => `
<div style="background: var(--bg-header); border: 1px solid var(--border); border-radius: 6px; padding: 6px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; gap: 4px; position: relative;">
<div style="font-size: 10px; font-weight: 700; color: var(--accent); font-family: var(--font-mono);">Page ${p.page_num}</div>
<div style="width: 44px; height: 58px; background: white; border: 1px solid var(--border); border-radius: 2px; display: flex; align-items: center; justify-content: center; transform: rotate(${p.rotation}deg); transition: transform 0.15s ease;">
<span style="color: black; font-size: 10px; font-family: var(--font-mono); font-weight: 700;">#${p.page_num}</span>
</div>
<div style="display: flex; gap: 2px; margin-top: 2px;">
<button class="btn btn-icon btn-sm" style="padding: 2px 4px; font-size: 9px;" onclick="rotatePdfOrganizerPage(${idx}, 90)" title="Rotate 90°">↻</button>
<button class="btn btn-icon btn-sm" style="padding: 2px 4px; font-size: 9px;" onclick="movePdfOrganizerPage(${idx}, -1)" ${idx === 0 ? 'disabled' : ''} title="Move Left">◀</button>
<button class="btn btn-icon btn-sm" style="padding: 2px 4px; font-size: 9px;" onclick="movePdfOrganizerPage(${idx}, 1)" ${idx === currentPdfOrganizerPages.length - 1 ? 'disabled' : ''} title="Move Right">▶</button>
<button class="btn btn-icon btn-sm" style="padding: 2px 4px; font-size: 9px; color: var(--danger);" onclick="removePdfOrganizerPage(${idx})" title="Delete Page">✕</button>
</div>
</div>
`).join('');
}
function rotatePdfOrganizerPage(idx, deg) {
if (currentPdfOrganizerPages[idx]) {
currentPdfOrganizerPages[idx].rotation = (currentPdfOrganizerPages[idx].rotation + deg) % 360;
renderPdfPageOrganizerGrid();
}
}
function movePdfOrganizerPage(idx, delta) {
const target = idx + delta;
if (target < 0 || target >= currentPdfOrganizerPages.length) return;
const item = currentPdfOrganizerPages.splice(idx, 1)[0];
currentPdfOrganizerPages.splice(target, 0, item);
renderPdfPageOrganizerGrid();
}
function removePdfOrganizerPage(idx) {
currentPdfOrganizerPages.splice(idx, 1);
renderPdfPageOrganizerGrid();
}
async function executePdfActiveTab() {
const btnMerge = document.getElementById('btn-pdf-execute-merge');
const btnSplit = document.getElementById('btn-pdf-execute-split');
const btnReorder = document.getElementById('btn-pdf-execute-reorder');
const buttons = [btnMerge, btnSplit, btnReorder].filter(Boolean);
buttons.forEach(b => b.disabled = true);
try {
if (activePdfTab === 'merge') {
const dest = document.getElementById('pdf-merge-dest')?.value?.trim();
const addBookmarks = document.getElementById('pdf-merge-add-bookmarks')?.checked ?? true;
if (currentPdfMergeList.length < 2) {
showToast('Please add at least 2 PDF documents to merge', 'warning');
return;
}
if (!dest) {
showToast('Please enter an output destination PDF path', 'warning');
return;
}
showToast('Merging PDF documents in pure Rust...', 'info');
const resp = await fetch('/api/tools/pdf/merge', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
sources: currentPdfMergeList.map(p => resolveAuthUri(p)),
destination: resolveAuthUri(dest),
add_bookmarks: addBookmarks
})
});
if (resp.ok) {
if (!isPdfDocked()) {
closeFloatingPdf();
}
showToast(`Successfully merged ${currentPdfMergeList.length} PDFs into ${dest.split('/').pop()}!`, 'success');
refreshAllPanes();
} else {
showToast(`PDF merge failed: ${await resp.text()}`, 'error');
}
} else if (activePdfTab === 'split') {
const source = document.getElementById('pdf-split-source')?.value?.trim();
const destDir = document.getElementById('pdf-split-dest-dir')?.value?.trim();
const mode = document.getElementById('pdf-split-mode')?.value || 'ranges';
const ranges = document.getElementById('pdf-split-ranges-val')?.value?.trim();
const chunkSize = parseInt(document.getElementById('pdf-split-chunk-val')?.value || '2', 10);
const prefix = document.getElementById('pdf-split-prefix')?.value?.trim() || null;
if (!source || !destDir) {
showToast('Please specify source PDF and output directory', 'warning');
return;
}
showToast('Splitting PDF document...', 'info');
const resp = await fetch('/api/tools/pdf/split', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: resolveAuthUri(source),
destination_dir: resolveAuthUri(destDir),
split_mode: mode,
page_ranges: mode === 'ranges' ? ranges : null,
chunk_size: mode === 'page_count' ? chunkSize : null,
output_prefix: prefix
})
});
if (resp.ok) {
const res = await resp.json();
if (!isPdfDocked()) {
closeFloatingPdf();
}
showToast(`PDF split into ${res.files.length} parts in ${destDir}!`, 'success');
refreshAllPanes();
} else {
showToast(`PDF split failed: ${await resp.text()}`, 'error');
}
} else if (activePdfTab === 'reorder') {
const source = document.getElementById('pdf-reorder-source')?.value?.trim();
const dest = document.getElementById('pdf-reorder-dest')?.value?.trim();
if (!source || !dest) {
showToast('Please specify source PDF and destination output path', 'warning');
return;
}
if (currentPdfOrganizerPages.length === 0) {
showToast('No pages to export in organizer', 'warning');
return;
}
showToast('Reordering & rotating PDF pages...', 'info');
const resp = await fetch('/api/tools/pdf/reorder', {
method: 'POST',
headers: { 'Authorization': `Bearer ${App.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: resolveAuthUri(source),
destination: resolveAuthUri(dest),
pages: currentPdfOrganizerPages
})
});
if (resp.ok) {
if (!isPdfDocked()) {
closeFloatingPdf();
}
showToast(`Successfully reordered and exported PDF to ${dest.split('/').pop()}!`, 'success');
refreshAllPanes();
} else {
showToast(`PDF reorder failed: ${await resp.text()}`, 'error');
}
}
} catch (e) {
showToast(`Error executing PDF operation: ${e}`, 'error');
} finally {
buttons.forEach(b => b.disabled = false);
}
}
function closeFloatingPdf() {
const win = document.getElementById('floating-pdf-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('pdf-pill');
if (pill) pill.style.display = 'none';
}
function minimizeFloatingPdf() {
const win = document.getElementById('floating-pdf-window');
const pill = document.getElementById('pdf-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingPdf() {
openPdfToolModal(null, activePdfTab);
}
function toggleMaximizePdf() {
const win = document.getElementById('floating-pdf-window');
if (win) win.classList.toggle('maximized');
}
function dockPdfToActivePane() {
closeFloatingPdf();
App.panes[App.activePaneIndex].dockedTool = 'pdf';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'pdf');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function mountDockedPdf(paneIndex) {
const host = document.getElementById(`docked-pdf-host-${paneIndex}`);
const floatingBody = document.getElementById('pdf-body');
if (host && floatingBody) {
host.appendChild(floatingBody);
setupPdfDropzone();
if (window.lucide) lucide.createIcons();
}
}
// ==========================================================================
// 🕹️ TETRADOG: CLASSIC ARCADE CHEWTOY & LEADERBOARD ENGINE
// ==========================================================================
const TETRADOG_PIECES = {
I: {
matrix: [
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0]
],
color: '#06b6d4',
glow: 'rgba(6, 182, 212, 0.7)'
},
J: {
matrix: [
[1, 0, 0],
[1, 1, 1],
[0, 0, 0]
],
color: '#3b82f6',
glow: 'rgba(59, 130, 246, 0.7)'
},
L: {
matrix: [
[0, 0, 1],
[1, 1, 1],
[0, 0, 0]
],
color: '#f97316',
glow: 'rgba(249, 115, 22, 0.7)'
},
O: {
matrix: [
[1, 1],
[1, 1]
],
color: '#eab308',
glow: 'rgba(234, 179, 8, 0.7)'
},
S: {
matrix: [
[0, 1, 1],
[1, 1, 0],
[0, 0, 0]
],
color: '#22c55e',
glow: 'rgba(34, 197, 94, 0.7)'
},
T: {
matrix: [
[0, 1, 0],
[1, 1, 1],
[0, 0, 0]
],
color: '#a855f7',
glow: 'rgba(168, 85, 247, 0.7)'
},
Z: {
matrix: [
[1, 1, 0],
[0, 1, 1],
[0, 0, 0]
],
color: '#ef4444',
glow: 'rgba(239, 68, 68, 0.7)'
}
};
const TETRADOG_SRS_KICKS_JLSTZ = {
'0->1': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
'1->0': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
'1->2': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
'2->1': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
'2->3': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
'3->2': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
'3->0': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
'0->3': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
};
const TETRADOG_SRS_KICKS_I = {
'0->1': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
'1->0': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
'1->2': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
'2->1': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
'2->3': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
'3->2': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
'3->0': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
'0->3': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
};
let tetradogState = {
initialized: false,
isMaximized: false,
activeView: 'game',
theme: localStorage.getItem('cd_tetradog_theme') || 'amber',
soundEnabled: localStorage.getItem('cd_tetradog_sound') !== '0',
volume: parseInt(localStorage.getItem('cd_tetradog_vol') || '60', 10),
playerAlias: localStorage.getItem('cd_tetradog_alias') || '',
config: {
startLevel: parseInt(localStorage.getItem('cd_tetradog_start_lvl') || '1', 10),
rotationSystem: localStorage.getItem('cd_tetradog_rotation') || 'srs',
ghostPiece: localStorage.getItem('cd_tetradog_ghost') !== '0',
das: parseInt(localStorage.getItem('cd_tetradog_das') || '133', 10),
arr: parseInt(localStorage.getItem('cd_tetradog_arr') || '16', 10),
scanlines: localStorage.getItem('cd_tetradog_scanlines') !== '0',
},
game: {
boardWidth: 10,
boardHeight: 20,
visibleHeight: 20,
cellSize: 24,
grid: [],
currentPiece: null,
nextQueue: [],
holdPiece: null,
canHold: true,
ghostY: 0,
status: 'ready', // 'ready' | 'playing' | 'paused' | 'gameover'
mode: 'marathon',
score: 0,
lines: 0,
level: 1,
highScore: 0,
b2b: false,
combo: 0,
startTime: 0,
elapsedMs: 0,
lastFrameTime: 0,
dropTimer: 0,
dropInterval: 1000,
lockTimer: 0,
lockDelay: 500,
lockResets: 0,
maxLockResets: 15,
isLocking: false,
animatingRows: [],
animationTimer: 0,
keyState: {},
dasTimer: 0,
arrTimer: 0,
dasDir: 0,
softDropActive: false,
lastActionWasRotate: false
},
leaderboard: {
mode: 'marathon',
scope: 'all',
list: [],
userStats: null,
isLoading: false
}
};
let tetraAudioCtx = null;
let tetraRafId = null;
let tetraDragInit = false;
// ---------------- AUDIO SYNTHESIZER (WEB AUDIO API) ----------------
function initTetraAudio() {
if (!tetraAudioCtx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
tetraAudioCtx = new AudioCtx();
}
}
if (tetraAudioCtx && tetraAudioCtx.state === 'suspended') {
tetraAudioCtx.resume();
}
}
function playTetraSound(type) {
if (!tetradogState.soundEnabled) return;
try {
initTetraAudio();
if (!tetraAudioCtx) return;
const masterVol = (tetradogState.volume / 100) * 0.15;
const now = tetraAudioCtx.currentTime;
if (type === 'move') {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(220, now);
osc.frequency.exponentialRampToValueAtTime(180, now + 0.035);
gain.gain.setValueAtTime(masterVol * 0.6, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.035);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.035);
} else if (type === 'rotate') {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(320, now);
osc.frequency.exponentialRampToValueAtTime(640, now + 0.045);
gain.gain.setValueAtTime(masterVol * 0.5, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.045);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.045);
} else if (type === 'harddrop') {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(140, now);
osc.frequency.exponentialRampToValueAtTime(35, now + 0.08);
gain.gain.setValueAtTime(masterVol * 1.0, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.08);
} else if (type === 'lock') {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(440, now);
osc.frequency.exponentialRampToValueAtTime(110, now + 0.04);
gain.gain.setValueAtTime(masterVol * 0.6, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.04);
} else if (type === 'hold') {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(350, now);
osc.frequency.linearRampToValueAtTime(480, now + 0.06);
gain.gain.setValueAtTime(masterVol * 0.7, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.06);
} else if (type === 'clear') {
[523.25, 659.25, 783.99].forEach((freq, idx) => {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, now + idx * 0.04);
gain.gain.setValueAtTime(masterVol * 0.6, now + idx * 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.04 + 0.09);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now + idx * 0.04);
osc.stop(now + idx * 0.04 + 0.09);
});
} else if (type === 'tetris') {
[523.25, 659.25, 783.99, 1046.50].forEach((freq, idx) => {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, now + idx * 0.05);
gain.gain.setValueAtTime(masterVol * 0.8, now + idx * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.05 + 0.22);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now + idx * 0.05);
osc.stop(now + idx * 0.05 + 0.22);
});
} else if (type === 'levelup') {
[440, 554.37, 659.25, 880].forEach((freq, idx) => {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now + idx * 0.04);
gain.gain.setValueAtTime(masterVol * 0.7, now + idx * 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.04 + 0.12);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now + idx * 0.04);
osc.stop(now + idx * 0.04 + 0.12);
});
} else if (type === 'gameover') {
[440, 415.30, 392.00, 349.23].forEach((freq, idx) => {
const osc = tetraAudioCtx.createOscillator();
const gain = tetraAudioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, now + idx * 0.09);
gain.gain.setValueAtTime(masterVol * 0.6, now + idx * 0.09);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.09 + 0.16);
osc.connect(gain);
gain.connect(tetraAudioCtx.destination);
osc.start(now + idx * 0.09);
osc.stop(now + idx * 0.09 + 0.16);
});
}
} catch (e) {
console.debug('TetraAudio error:', e);
}
}
// ---------------- WINDOWING & LIFECYCLE ----------------
function openTetraDog() {
closeToolsMenu();
const tetrionPlugin = (window.installedChewToys || []).find(p => (p.id === 'tetrion' || p.id === 'arcade-blocks') && (p.is_enabled !== undefined ? p.is_enabled : (p.enabled !== undefined ? p.enabled : true)));
if (tetrionPlugin) {
openDynamicChewToy(tetrionPlugin.id);
return;
}
const win = document.getElementById('floating-tetradog-window');
const pill = document.getElementById('tetradog-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
updateDynamicViewportHeight();
setTetraDogView('game');
initTetraDogDragAndResize();
initTetraDogInput();
applyTetraDogTheme();
if (!tetradogState.initialized) {
initTetraDogGame();
tetradogState.initialized = true;
}
// Auto-start game immediately on open if ready or gameover so pieces start falling!
if (tetradogState.game.status === 'ready' || tetradogState.game.status === 'gameover') {
startTetraDogGame();
}
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
// Load hiscores from DB in background
loadTetraDogLeaderboard(tetradogState.game.mode, false);
}
function closeTetraDog() {
const win = document.getElementById('floating-tetradog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('tetradog-pill');
if (pill) pill.style.display = 'none';
if (tetradogState.game.status === 'playing') {
pauseTetraDogGame();
}
}
function minimizeTetraDog() {
const win = document.getElementById('floating-tetradog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('tetradog-pill');
if (pill) {
pill.style.display = 'flex';
const scoreEl = document.getElementById('tetradog-pill-score');
if (scoreEl) scoreEl.textContent = tetradogState.game.score.toLocaleString();
}
}
function restoreTetraDog() {
openTetraDog();
}
function maximizeTetraDog() {
const win = document.getElementById('floating-tetradog-window');
if (!win) return;
tetradogState.isMaximized = !tetradogState.isMaximized;
win.classList.toggle('maximized', tetradogState.isMaximized);
}
function setTetraDogView(viewName) {
tetradogState.activeView = viewName;
['game', 'leaderboard', 'settings'].forEach(v => {
const el = document.getElementById(`tetradog-view-${v}`);
const btn = document.getElementById(`btn-tetradog-view-${v}`);
if (el) el.style.display = (v === viewName) ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', v === viewName);
});
if (viewName === 'leaderboard') {
loadTetraDogLeaderboard(tetradogState.leaderboard.mode);
} else if (viewName === 'game') {
renderTetraCanvas();
}
}
function setTetraDogTheme(theme) {
tetradogState.theme = theme;
localStorage.setItem('cd_tetradog_theme', theme);
applyTetraDogTheme();
}
function cycleTetraDogTheme() {
const themes = ['amber', 'gameboy', 'nes', 'cyberpunk'];
const curIdx = themes.indexOf(tetradogState.theme);
const nextTheme = themes[(curIdx + 1) % themes.length];
setTetraDogTheme(nextTheme);
showToast(`TetraDog Theme: ${nextTheme.toUpperCase()}`, 'info');
}
function applyTetraDogTheme() {
const win = document.getElementById('floating-tetradog-window');
if (!win) return;
win.classList.remove('tetradog-theme-amber', 'tetradog-theme-gameboy', 'tetradog-theme-nes', 'tetradog-theme-cyberpunk');
win.classList.add(`tetradog-theme-${tetradogState.theme}`);
const themeSelect = document.getElementById('tetradog-cfg-theme');
if (themeSelect) themeSelect.value = tetradogState.theme;
renderTetraCanvas();
}
function toggleTetraDogAudio() {
tetradogState.soundEnabled = !tetradogState.soundEnabled;
localStorage.setItem('cd_tetradog_sound', tetradogState.soundEnabled ? '1' : '0');
const icon = document.getElementById('icon-tetradog-audio');
const btn = document.getElementById('btn-tetradog-audio');
if (icon) {
icon.setAttribute('data-lucide', tetradogState.soundEnabled ? 'volume-2' : 'volume-x');
if (window.lucide) lucide.createIcons({ root: icon.parentElement });
}
if (btn) {
btn.classList.toggle('muted', !tetradogState.soundEnabled);
btn.title = tetradogState.soundEnabled ? 'Sound Effects (Click to Mute)' : 'Sound Muted (Click to Unmute)';
}
const chk = document.getElementById('tetradog-cfg-sound');
if (chk) chk.checked = tetradogState.soundEnabled;
showToast(tetradogState.soundEnabled ? 'Sound Enabled' : 'Sound Muted', 'info');
}
function updateTetraDogConfig(key, val) {
tetradogState.config[key] = val;
if (key === 'startLevel') {
localStorage.setItem('cd_tetradog_start_lvl', val);
if (tetradogState.game.status === 'ready') {
tetradogState.game.level = val;
updateTetraHeaderAndStats();
}
} else if (key === 'rotationSystem') {
localStorage.setItem('cd_tetradog_rotation', val);
} else if (key === 'ghostPiece') {
localStorage.setItem('cd_tetradog_ghost', val ? '1' : '0');
renderTetraCanvas();
} else if (key === 'das') {
localStorage.setItem('cd_tetradog_das', val);
} else if (key === 'arr') {
localStorage.setItem('cd_tetradog_arr', val);
} else if (key === 'scanlines') {
localStorage.setItem('cd_tetradog_scanlines', val ? '1' : '0');
const scanlinesEl = document.getElementById('tetradog-scanlines');
if (scanlinesEl) scanlinesEl.style.display = val ? 'block' : 'none';
} else if (key === 'volume') {
tetradogState.volume = val;
localStorage.setItem('cd_tetradog_vol', val);
}
}
function initTetraDogDragAndResize() {
if (tetraDragInit) return;
tetraDragInit = true;
const win = document.getElementById('floating-tetradog-window');
const header = document.getElementById('tetradog-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_tetradog_x');
const savedTop = localStorage.getItem('cd_tetradog_y');
const savedW = localStorage.getItem('cd_tetradog_w');
const savedH = localStorage.getItem('cd_tetradog_h');
if (savedW) win.style.width = `${Math.max(440, parseInt(savedW, 10))}px`;
if (savedH) win.style.height = `${Math.max(520, parseInt(savedH, 10))}px`;
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 400, Math.max(10, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 400, Math.max(40, parseInt(savedTop, 10)))}px`;
}
// Header Dragging
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 640 || win.classList.contains('maximized')) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - win.offsetWidth, winStartX + dx));
const newTop = Math.max(36, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newLeft}px`;
win.style.top = `${newTop}px`;
win.style.right = 'auto';
localStorage.setItem('cd_tetradog_x', newLeft);
localStorage.setItem('cd_tetradog_y', newTop);
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
}
});
// Window Resizers
const handles = {
top: document.getElementById('tetradog-resize-top'),
bottom: document.getElementById('tetradog-resize-bottom'),
left: document.getElementById('tetradog-resize-left'),
right: document.getElementById('tetradog-resize-right'),
corner: document.getElementById('tetradog-resize-corner')
};
let resizeMode = null;
let rStartX = 0, rStartY = 0;
let rStartW = 0, rStartH = 0;
let rStartLeft = 0, rStartTop = 0;
Object.entries(handles).forEach(([mode, handle]) => {
if (!handle) return;
handle.addEventListener('mousedown', (e) => {
if (win.classList.contains('maximized')) return;
e.preventDefault();
e.stopPropagation();
resizeMode = mode;
rStartX = e.clientX;
rStartY = e.clientY;
const rect = win.getBoundingClientRect();
rStartW = rect.width;
rStartH = rect.height;
rStartLeft = rect.left;
rStartTop = rect.top;
document.body.style.userSelect = 'none';
const onMouseMove = (moveEvent) => {
if (!resizeMode) return;
const dx = moveEvent.clientX - rStartX;
const dy = moveEvent.clientY - rStartY;
const minW = 440;
const minH = 520;
if (resizeMode === 'right' || resizeMode === 'corner') {
const newW = Math.max(minW, rStartW + dx);
win.style.width = `${newW}px`;
localStorage.setItem('cd_tetradog_w', newW);
}
if (resizeMode === 'bottom' || resizeMode === 'corner') {
const newH = Math.max(minH, rStartH + dy);
win.style.height = `${newH}px`;
localStorage.setItem('cd_tetradog_h', newH);
}
if (resizeMode === 'left') {
const newW = Math.max(minW, rStartW - dx);
if (newW > minW) {
win.style.width = `${newW}px`;
win.style.left = `${rStartLeft + dx}px`;
localStorage.setItem('cd_tetradog_w', newW);
localStorage.setItem('cd_tetradog_x', rStartLeft + dx);
}
}
if (resizeMode === 'top') {
const newH = Math.max(minH, rStartH - dy);
if (newH > minH) {
win.style.height = `${newH}px`;
win.style.top = `${rStartTop + dy}px`;
localStorage.setItem('cd_tetradog_h', newH);
localStorage.setItem('cd_tetradog_y', rStartTop + dy);
}
}
};
const onMouseUp = () => {
resizeMode = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
});
}
// ---------------- GAME ENGINE IMPLEMENTATION ----------------
function initTetraDogGame() {
const g = tetradogState.game;
g.boardWidth = 10;
g.boardHeight = 20;
g.visibleHeight = 20;
g.grid = createEmptyTetraGrid(g.boardHeight, g.boardWidth);
g.nextQueue = [];
g.holdPiece = null;
g.canHold = true;
g.score = 0;
g.lines = 0;
g.level = tetradogState.config.startLevel;
g.b2b = false;
g.combo = 0;
g.status = 'ready';
g.dropInterval = getTetraGravityMs(g.level);
// Fill next queue
while (g.nextQueue.length < 5) {
g.nextQueue.push(...generateTetraBag());
}
updateTetraHeaderAndStats();
renderTetraCanvas();
renderHoldCanvas();
renderNextCanvas();
// Start fixed loop
if (!tetraRafId) {
g.lastFrameTime = performance.now();
tetraRafId = requestAnimationFrame(tetraGameLoop);
}
}
function createEmptyTetraGrid(rows, cols) {
const grid = [];
for (let r = 0; r < rows; r++) {
grid.push(new Array(cols).fill(0));
}
return grid;
}
function generateTetraBag() {
const bag = ['I', 'J', 'L', 'O', 'S', 'T', 'Z'];
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
return bag;
}
function getTetraGravityMs(level) {
// Master gravity scaling: Level 1 = 1000ms, Level 10 = ~120ms, Level 20 = 20ms
const clampedLvl = Math.min(20, Math.max(1, level));
const ms = Math.pow(0.8 - ((clampedLvl - 1) * 0.007), clampedLvl - 1) * 1000;
return Math.max(20, Math.floor(ms));
}
function startTetraDogGame() {
initTetraAudio();
const g = tetradogState.game;
g.boardWidth = 10;
g.boardHeight = 20;
g.visibleHeight = 20;
g.grid = createEmptyTetraGrid(g.boardHeight, g.boardWidth);
g.nextQueue = [];
g.holdPiece = null;
g.canHold = true;
g.score = 0;
g.lines = 0;
g.level = tetradogState.config.startLevel;
g.b2b = false;
g.combo = 0;
g.dropInterval = getTetraGravityMs(g.level);
g.dropTimer = 0;
g.lockTimer = 0;
g.lockResets = 0;
g.isLocking = false;
g.animatingRows = [];
g.startTime = Date.now();
g.elapsedMs = 0;
g.status = 'playing';
while (g.nextQueue.length < 5) {
g.nextQueue.push(...generateTetraBag());
}
spawnTetraPiece();
// Hide Overlays
const readyOverlay = document.getElementById('tetradog-ready-overlay');
if (readyOverlay) readyOverlay.style.display = 'none';
const pauseOverlay = document.getElementById('tetradog-pause-overlay');
if (pauseOverlay) pauseOverlay.style.display = 'none';
const goOverlay = document.getElementById('tetradog-gameover-overlay');
if (goOverlay) goOverlay.style.display = 'none';
updatePlayPauseButtons();
updateTetraHeaderAndStats();
playTetraSound('levelup');
if (!tetraRafId) {
g.lastFrameTime = performance.now();
tetraRafId = requestAnimationFrame(tetraGameLoop);
}
}
function pauseTetraDogGame() {
const g = tetradogState.game;
if (g.status !== 'playing') return;
g.status = 'paused';
document.getElementById('tetradog-pause-overlay').style.display = 'flex';
updatePlayPauseButtons();
}
function resumeTetraDogGame() {
const g = tetradogState.game;
if (g.status !== 'paused') return;
g.status = 'playing';
g.lastFrameTime = performance.now();
document.getElementById('tetradog-pause-overlay').style.display = 'none';
updatePlayPauseButtons();
}
function toggleTetraDogPlayPause() {
const g = tetradogState.game;
if (g.status === 'ready' || g.status === 'gameover') {
startTetraDogGame();
} else if (g.status === 'playing') {
pauseTetraDogGame();
} else if (g.status === 'paused') {
resumeTetraDogGame();
}
}
function restartTetraDogGame() {
startTetraDogGame();
}
function updatePlayPauseButtons() {
const g = tetradogState.game;
const label = document.getElementById('label-tetradog-play-pause');
const icon = document.getElementById('icon-tetradog-play-pause');
if (label && icon) {
if (g.status === 'playing') {
label.textContent = 'Pause';
icon.setAttribute('data-lucide', 'pause');
} else {
label.textContent = 'Play';
icon.setAttribute('data-lucide', 'play');
}
if (window.lucide) lucide.createIcons({ root: label.parentElement });
}
}
function spawnTetraPiece() {
const g = tetradogState.game;
if (g.nextQueue.length < 5) {
g.nextQueue.push(...generateTetraBag());
}
const type = g.nextQueue.shift();
const def = TETRADOG_PIECES[type];
const matrix = JSON.parse(JSON.stringify(def.matrix));
const startX = Math.floor((g.boardWidth - matrix[0].length) / 2);
const startY = (type === 'I') ? -1 : 0;
g.currentPiece = {
type,
matrix,
x: startX,
y: startY,
rotation: 0
};
g.canHold = true;
g.lockTimer = 0;
g.lockResets = 0;
g.isLocking = false;
g.lastActionWasRotate = false;
// Collision on spawn = Game Over (Top out)
if (checkTetraCollision(g.grid, g.currentPiece)) {
endTetraDogGame('topout');
return;
}
calculateGhostY();
renderHoldCanvas();
renderNextCanvas();
renderTetraCanvas();
}
function holdTetraPiece() {
const g = tetradogState.game;
if (g.status !== 'playing' || !g.canHold || !g.currentPiece) return;
playTetraSound('hold');
const curType = g.currentPiece.type;
if (g.holdPiece) {
const swapType = g.holdPiece;
g.holdPiece = curType;
const def = TETRADOG_PIECES[swapType];
g.currentPiece = {
type: swapType,
matrix: JSON.parse(JSON.stringify(def.matrix)),
x: Math.floor((g.boardWidth - def.matrix[0].length) / 2),
y: (swapType === 'I') ? -1 : 0,
rotation: 0
};
} else {
g.holdPiece = curType;
spawnTetraPiece();
}
g.canHold = false;
calculateGhostY();
renderHoldCanvas();
renderTetraCanvas();
}
function moveTetraPiece(dx, dy) {
const g = tetradogState.game;
if (g.status !== 'playing' || !g.currentPiece) return false;
const testPiece = {
...g.currentPiece,
x: g.currentPiece.x + dx,
y: g.currentPiece.y + dy
};
if (!checkTetraCollision(g.grid, testPiece)) {
g.currentPiece.x += dx;
g.currentPiece.y += dy;
g.lastActionWasRotate = false;
if (dx !== 0) {
playTetraSound('move');
if (g.isLocking && g.lockResets < g.maxLockResets) {
g.lockTimer = 0;
g.lockResets++;
}
}
if (dy > 0) {
g.score += 1; // Soft drop bonus
updateTetraHeaderAndStats();
}
calculateGhostY();
return true;
}
return false;
}
function rotateTetraPiece(dir = 1) { // 1 = CW, -1 = CCW
const g = tetradogState.game;
if (g.status !== 'playing' || !g.currentPiece) return;
const p = g.currentPiece;
const oldRot = p.rotation;
const newRot = (oldRot + dir + 4) % 4;
const rotatedMatrix = rotateMatrix(p.matrix, dir);
// SRS Wall Kick test
if (tetradogState.config.rotationSystem === 'srs') {
const kickKey = `${oldRot}->${newRot}`;
const kicks = (p.type === 'I')
? (TETRADOG_SRS_KICKS_I[kickKey] || [[0, 0]])
: (TETRADOG_SRS_KICKS_JLSTZ[kickKey] || [[0, 0]]);
for (let i = 0; i < kicks.length; i++) {
const [kx, ky] = kicks[i];
const testPiece = {
...p,
matrix: rotatedMatrix,
x: p.x + kx,
y: p.y - ky, // Inverted Y for screen coords
rotation: newRot
};
if (!checkTetraCollision(g.grid, testPiece)) {
p.matrix = rotatedMatrix;
p.x += kx;
p.y -= ky;
p.rotation = newRot;
p.lastActionWasRotate = true;
playTetraSound('rotate');
if (g.isLocking && g.lockResets < g.maxLockResets) {
g.lockTimer = 0;
g.lockResets++;
}
calculateGhostY();
return;
}
}
} else {
// Basic rotation
if (!checkTetraCollision(g.grid, { ...p, matrix: rotatedMatrix, rotation: newRot })) {
p.matrix = rotatedMatrix;
p.rotation = newRot;
p.lastActionWasRotate = true;
playTetraSound('rotate');
calculateGhostY();
}
}
}
function rotateMatrix(matrix, dir = 1) {
const N = matrix.length;
const res = [];
for (let r = 0; r < N; r++) {
res.push(new Array(N).fill(0));
}
if (dir === 1) {
// Clockwise: transpose + reverse each row
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
res[c][N - 1 - r] = matrix[r][c];
}
}
} else {
// Counter-Clockwise
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
res[N - 1 - c][r] = matrix[r][c];
}
}
}
return res;
}
function hardDropTetraPiece() {
const g = tetradogState.game;
if (g.status !== 'playing' || !g.currentPiece) return;
let droppedRows = 0;
while (!checkTetraCollision(g.grid, { ...g.currentPiece, y: g.currentPiece.y + 1 })) {
g.currentPiece.y++;
droppedRows++;
}
g.score += droppedRows * 2; // Hard drop bonus
playTetraSound('harddrop');
lockTetraPiece();
}
function calculateGhostY() {
const g = tetradogState.game;
if (!g.currentPiece) return;
let gy = g.currentPiece.y;
while (!checkTetraCollision(g.grid, { ...g.currentPiece, y: gy + 1 })) {
gy++;
}
g.ghostY = gy;
}
function checkTetraCollision(grid, piece) {
const { matrix, x, y } = piece;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const boardX = x + c;
const boardY = y + r;
if (boardX < 0 || boardX >= 10 || boardY >= 20) {
return true; // Wall or floor collision
}
if (boardY >= 0 && grid[boardY] && grid[boardY][boardX]) {
return true; // Occupied cell collision
}
}
}
}
return false;
}
function lockTetraPiece() {
const g = tetradogState.game;
if (!g.currentPiece) return;
const { matrix, x, y, type } = g.currentPiece;
let topOut = false;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const by = y + r;
const bx = x + c;
if (by < 0) {
topOut = true;
} else if (by < g.boardHeight && bx >= 0 && bx < g.boardWidth) {
g.grid[by][bx] = type;
}
}
}
}
if (topOut) {
endTetraDogGame('topout');
return;
}
playTetraSound('lock');
g.currentPiece = null;
g.isLocking = false;
// Check lines to clear
checkLineClears();
}
function checkLineClears() {
const g = tetradogState.game;
const fullRows = [];
for (let r = 0; r < g.boardHeight; r++) {
if (g.grid[r].every(cell => cell !== 0)) {
fullRows.push(r);
}
}
if (fullRows.length > 0) {
g.animatingRows = fullRows;
g.animationTimer = 120; // 120ms flash animation
// T-Spin detection: 3 corners filled of T piece
const isTetris = fullRows.length === 4;
if (isTetris) {
if (g.b2b) {
g.score += Math.floor(1200 * g.level * 1.5);
} else {
g.score += 800 * g.level;
}
g.b2b = true;
playTetraSound('tetris');
} else {
const scoresMap = { 1: 100, 2: 300, 3: 500 };
g.score += (scoresMap[fullRows.length] || 100) * g.level;
g.b2b = false;
playTetraSound('clear');
}
g.lines += fullRows.length;
g.combo++;
// Level up check
const newLevel = tetradogState.config.startLevel + Math.floor(g.lines / 10);
if (newLevel > g.level) {
g.level = newLevel;
g.dropInterval = getTetraGravityMs(g.level);
playTetraSound('levelup');
}
// Sprint Mode check (40 lines clear)
if (g.mode === 'sprint' && g.lines >= 40) {
endTetraDogGame('sprint_complete');
return;
}
} else {
g.combo = 0;
spawnTetraPiece();
}
updateTetraHeaderAndStats();
}
function executeLineCollapse() {
const g = tetradogState.game;
if (g.animatingRows.length === 0) return;
g.animatingRows.sort((a, b) => a - b);
g.animatingRows.forEach(rowIdx => {
g.grid.splice(rowIdx, 1);
g.grid.unshift(new Array(g.boardWidth).fill(0));
});
g.animatingRows = [];
spawnTetraPiece();
}
function endTetraDogGame(reason = 'topout') {
const g = tetradogState.game;
g.status = 'gameover';
playTetraSound('gameover');
const durationSec = Math.floor(g.elapsedMs / 1000);
const titleEl = document.getElementById('tetradog-gameover-title');
if (titleEl) {
if (reason === 'sprint_complete') {
titleEl.textContent = 'SPRINT FINISHED!';
titleEl.className = 'tetradog-overlay-title tetradog-accent-glow';
} else if (reason === 'time_up') {
titleEl.textContent = 'TIME IS UP!';
titleEl.className = 'tetradog-overlay-title tetradog-accent-glow';
} else {
titleEl.textContent = 'GAME OVER';
titleEl.className = 'tetradog-overlay-title tetradog-danger-glow';
}
}
document.getElementById('tetradog-go-score').textContent = g.score.toLocaleString();
document.getElementById('tetradog-go-level').textContent = g.level;
document.getElementById('tetradog-go-lines').textContent = g.lines;
document.getElementById('tetradog-go-time').textContent = formatTetraTimer(g.elapsedMs);
document.getElementById('tetradog-gameover-overlay').style.display = 'flex';
updatePlayPauseButtons();
// Submit high score to backend SQLite database
submitTetraDogScore(g.score, g.lines, g.level, durationSec, g.mode);
}
function changeTetraDogMode(mode) {
tetradogState.game.mode = mode;
updateTetraHeaderAndStats();
const timerRow = document.getElementById('tetradog-timer-row');
if (timerRow) {
timerRow.style.display = (mode === 'sprint' || mode === 'ultra') ? 'flex' : 'none';
}
if (tetradogState.game.status === 'ready') {
initTetraDogGame();
}
}
// ---------------- GAME PHYSICS LOOP (60 FPS FIXED TIMESTEP) ----------------
function tetraGameLoop(timestamp) {
const g = tetradogState.game;
const delta = timestamp - (g.lastFrameTime || timestamp);
g.lastFrameTime = timestamp;
if (g.status === 'playing') {
g.elapsedMs += delta;
// Ultra Mode countdown check (180 seconds)
if (g.mode === 'ultra' && g.elapsedMs >= 180000) {
endTetraDogGame('time_up');
return;
}
// Timer display update
const timerEl = document.getElementById('tetradog-stat-timer');
if (timerEl && (g.mode === 'sprint' || g.mode === 'ultra')) {
if (g.mode === 'ultra') {
const remain = Math.max(0, 180000 - g.elapsedMs);
timerEl.textContent = formatTetraTimer(remain);
} else {
timerEl.textContent = formatTetraTimer(g.elapsedMs);
}
}
// Line clear flash animation
if (g.animatingRows.length > 0) {
g.animationTimer -= delta;
if (g.animationTimer <= 0) {
executeLineCollapse();
}
} else if (g.currentPiece) {
// DAS / ARR Input Processing
handleTetraContinuousInput(delta);
// Gravity Drop Timer
g.dropTimer += delta;
const currentInterval = g.softDropActive ? Math.min(40, g.dropInterval / 12) : g.dropInterval;
if (g.dropTimer >= currentInterval) {
g.dropTimer = 0;
const moved = moveTetraPiece(0, 1);
if (!moved) {
g.isLocking = true;
}
}
// Lock Delay Timer
if (g.isLocking) {
g.lockTimer += delta;
if (g.lockTimer >= g.lockDelay) {
lockTetraPiece();
}
}
}
}
renderTetraCanvas();
tetraRafId = requestAnimationFrame(tetraGameLoop);
}
function handleTetraContinuousInput(delta) {
const g = tetradogState.game;
if (!g.currentPiece || g.dasDir === 0) return;
g.dasTimer += delta;
if (g.dasTimer >= tetradogState.config.das) {
g.arrTimer += delta;
const arrSpeed = Math.max(0, tetradogState.config.arr);
if (arrSpeed === 0) {
// Instant shift
while (moveTetraPiece(g.dasDir, 0)) {}
} else if (g.arrTimer >= arrSpeed) {
g.arrTimer = 0;
moveTetraPiece(g.dasDir, 0);
}
}
}
// ---------------- CANVAS RENDERING ----------------
function renderTetraCanvas() {
const canvas = document.getElementById('tetradog-board-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const g = tetradogState.game;
const w = canvas.width;
const h = canvas.height;
const cellSize = w / g.boardWidth;
// Clear background
ctx.fillStyle = (tetradogState.theme === 'gameboy') ? '#0f380f' : '#040507';
ctx.fillRect(0, 0, w, h);
// Subtle Grid Lines
ctx.strokeStyle = (tetradogState.theme === 'gameboy') ? 'rgba(48, 98, 48, 0.3)' : 'rgba(255, 255, 255, 0.04)';
ctx.lineWidth = 1;
for (let c = 1; c < g.boardWidth; c++) {
ctx.beginPath();
ctx.moveTo(c * cellSize, 0);
ctx.lineTo(c * cellSize, h);
ctx.stroke();
}
for (let r = 1; r < g.boardHeight; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellSize);
ctx.lineTo(w, r * cellSize);
ctx.stroke();
}
// Draw Settled Blocks on Board
for (let r = 0; r < g.boardHeight; r++) {
const isFlashing = g.animatingRows.includes(r);
for (let c = 0; c < g.boardWidth; c++) {
const type = g.grid[r] ? g.grid[r][c] : 0;
if (type && type !== 0) {
if (isFlashing) {
drawTetraBlock(ctx, c * cellSize, r * cellSize, cellSize, '#ffffff', 'rgba(255, 255, 255, 0.8)');
} else {
const pieceDef = TETRADOG_PIECES[type];
if (pieceDef) {
drawTetraBlock(ctx, c * cellSize, r * cellSize, cellSize, pieceDef.color, pieceDef.glow);
}
}
}
}
}
// Draw Ghost Piece
if (tetradogState.config.ghostPiece && g.currentPiece && (g.status === 'playing' || g.status === 'paused')) {
const { matrix, x } = g.currentPiece;
const gy = g.ghostY;
const pieceDef = TETRADOG_PIECES[g.currentPiece.type];
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const drawY = gy + r;
if (drawY >= 0 && drawY < g.boardHeight) {
drawGhostBlock(ctx, (x + c) * cellSize, drawY * cellSize, cellSize, pieceDef ? pieceDef.color : '#f59e0b');
}
}
}
}
}
// Draw Active Falling Piece
if (g.currentPiece && (g.status === 'playing' || g.status === 'paused')) {
const { matrix, x, y, type } = g.currentPiece;
const pieceDef = TETRADOG_PIECES[type];
if (pieceDef) {
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
const drawY = y + r;
if (drawY >= 0 && drawY < g.boardHeight) {
drawTetraBlock(ctx, (x + c) * cellSize, drawY * cellSize, cellSize, pieceDef.color, pieceDef.glow);
}
}
}
}
}
}
}
function drawTetraBlock(ctx, x, y, size, color, glow) {
ctx.save();
const theme = tetradogState.theme;
if (theme === 'gameboy') {
ctx.fillStyle = '#8bac0f';
ctx.fillRect(x + 1, y + 1, size - 2, size - 2);
ctx.fillStyle = '#306230';
ctx.fillRect(x + 3, y + 3, size - 6, size - 6);
ctx.fillStyle = '#9bbc0f';
ctx.fillRect(x + 4, y + 4, size - 8, size - 8);
} else {
// 3D Beveled Arcade Block
ctx.fillStyle = color;
ctx.fillRect(x + 1, y + 1, size - 2, size - 2);
// Light top/left bevel
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.beginPath();
ctx.moveTo(x + 1, y + 1);
ctx.lineTo(x + size - 1, y + 1);
ctx.lineTo(x + size - 4, y + 4);
ctx.lineTo(x + 4, y + 4);
ctx.lineTo(x + 4, y + size - 4);
ctx.lineTo(x + 1, y + size - 1);
ctx.closePath();
ctx.fill();
// Dark bottom/right bevel
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.beginPath();
ctx.moveTo(x + size - 1, y + 1);
ctx.lineTo(x + size - 1, y + size - 1);
ctx.lineTo(x + 1, y + size - 1);
ctx.lineTo(x + 4, y + size - 4);
ctx.lineTo(x + size - 4, y + size - 4);
ctx.lineTo(x + size - 4, y + 4);
ctx.closePath();
ctx.fill();
// Center jewel
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.fillRect(x + 6, y + 6, size - 12, size - 12);
}
ctx.restore();
}
function drawGhostBlock(ctx, x, y, size, color) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.strokeRect(x + 2, y + 2, size - 4, size - 4);
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
ctx.fillRect(x + 2, y + 2, size - 4, size - 4);
ctx.restore();
}
function renderHoldCanvas() {
const canvas = document.getElementById('tetradog-hold-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const holdType = tetradogState.game.holdPiece;
if (!holdType) return;
const def = TETRADOG_PIECES[holdType];
const matrix = def.matrix;
const cellSize = 18;
const offsetX = (canvas.width - matrix[0].length * cellSize) / 2;
const offsetY = (canvas.height - matrix.length * cellSize) / 2;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
drawTetraBlock(ctx, offsetX + c * cellSize, offsetY + r * cellSize, cellSize, def.color, def.glow);
}
}
}
}
function renderNextCanvas() {
const canvas = document.getElementById('tetradog-next-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const queue = tetradogState.game.nextQueue.slice(0, 3);
const cellSize = 16;
queue.forEach((type, idx) => {
const def = TETRADOG_PIECES[type];
const matrix = def.matrix;
const offsetX = (canvas.width - matrix[0].length * cellSize) / 2;
const offsetY = 12 + idx * 64 + (48 - matrix.length * cellSize) / 2;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (matrix[r][c]) {
drawTetraBlock(ctx, offsetX + c * cellSize, offsetY + r * cellSize, cellSize, def.color, def.glow);
}
}
}
});
}
function updateTetraHeaderAndStats() {
const g = tetradogState.game;
// Status bar pills
const sbScore = document.getElementById('tetradog-sb-score-val');
const sbLvl = document.getElementById('tetradog-sb-lvl-val');
const sbLines = document.getElementById('tetradog-sb-lines-val');
if (sbScore) sbScore.textContent = g.score.toLocaleString();
if (sbLvl) sbLvl.textContent = g.level;
if (sbLines) sbLines.textContent = g.lines;
// Header pills fallback (if present)
const hdrScore = document.getElementById('tetradog-hdr-score-val');
const hdrLvl = document.getElementById('tetradog-hdr-lvl-val');
const hdrLines = document.getElementById('tetradog-hdr-lines-val');
if (hdrScore) hdrScore.textContent = g.score.toLocaleString();
if (hdrLvl) hdrLvl.textContent = g.level;
if (hdrLines) hdrLines.textContent = g.lines;
// Minimized pill
const pillScore = document.getElementById('tetradog-pill-score');
if (pillScore) pillScore.textContent = g.score.toLocaleString();
// Arena stats (Top score)
const hiScoreEl = document.getElementById('tetradog-stat-hiscore');
if (hiScoreEl) hiScoreEl.textContent = g.highScore.toLocaleString();
const mobHiScore = document.getElementById('tetradog-mob-hiscore');
if (mobHiScore) mobHiScore.textContent = g.highScore.toLocaleString();
// Arena mode badge & select
const arenaBadge = document.getElementById('tetradog-arena-mode-badge');
if (arenaBadge) arenaBadge.textContent = g.mode.toUpperCase();
const mobModeBadge = document.getElementById('tetradog-mobile-mode-badge');
if (mobModeBadge) mobModeBadge.textContent = g.mode.toUpperCase();
const modeSel = document.getElementById('tetradog-mode-select');
if (modeSel && modeSel.value !== g.mode) modeSel.value = g.mode;
// Sound button status styling
const soundBtn = document.getElementById('btn-tetradog-audio');
if (soundBtn) {
soundBtn.classList.toggle('muted', !tetradogState.soundEnabled);
}
// B2B & Combo Badges
const b2bBadge = document.getElementById('tetradog-b2b-badge');
if (b2bBadge) b2bBadge.style.display = g.b2b ? 'block' : 'none';
const comboBadge = document.getElementById('tetradog-combo-badge');
const comboCnt = document.getElementById('tetradog-combo-count');
if (comboBadge && comboCnt) {
if (g.combo > 1) {
comboCnt.textContent = g.combo;
comboBadge.style.display = 'block';
} else {
comboBadge.style.display = 'none';
}
}
}
function formatTetraTimer(ms) {
const totalSec = Math.floor(ms / 1000);
const minutes = Math.floor(totalSec / 60);
const seconds = totalSec % 60;
const tenths = Math.floor((ms % 1000) / 100);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${tenths}`;
}
// ---------------- INPUT EVENT LISTENERS (KEYBOARD & TOUCH) ----------------
function initTetraDogInput() {
if (window._tetraInputInitialized) return;
window._tetraInputInitialized = true;
window.addEventListener('keydown', (e) => {
const win = document.getElementById('floating-tetradog-window');
const isFloatingOpen = win && win.style.display !== 'none';
const isDocked = App.panes.some(p => p.dockedTool === 'tetradog');
if (!isFloatingOpen && !isDocked) return;
if (tetradogState.activeView !== 'game') return;
// Ignore if user is typing in an input
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
const g = tetradogState.game;
if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') {
e.preventDefault();
toggleTetraDogPlayPause();
return;
}
if (g.status === 'ready' || g.status === 'gameover') {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Enter', 'w', 'a', 's', 'd', 'z', 'x', 'c'].includes(e.key)) {
e.preventDefault();
startTetraDogGame();
return;
}
}
if (g.status !== 'playing') return;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
e.preventDefault();
if (g.dasDir !== -1) {
g.dasDir = -1;
g.dasTimer = 0;
g.arrTimer = 0;
moveTetraPiece(-1, 0);
}
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
e.preventDefault();
if (g.dasDir !== 1) {
g.dasDir = 1;
g.dasTimer = 0;
g.arrTimer = 0;
moveTetraPiece(1, 0);
}
} else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
e.preventDefault();
g.softDropActive = true;
} else if (e.key === 'ArrowUp' || e.key === 'x' || e.key === 'X' || e.key === 'e' || e.key === 'E') {
e.preventDefault();
rotateTetraPiece(1);
} else if (e.key === 'z' || e.key === 'Z' || e.key === 'Control' || e.key === 'q' || e.key === 'Q') {
e.preventDefault();
rotateTetraPiece(-1);
} else if (e.key === ' ' || e.key === 'w' || e.key === 'W') {
e.preventDefault();
hardDropTetraPiece();
} else if (e.key === 'c' || e.key === 'C' || e.key === 'Shift') {
e.preventDefault();
holdTetraPiece();
}
});
window.addEventListener('keyup', (e) => {
const g = tetradogState.game;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
if (g.dasDir === -1) g.dasDir = 0;
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
if (g.dasDir === 1) g.dasDir = 0;
} else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
g.softDropActive = false;
}
});
// Touch Virtual D-Pad & NES Action buttons
const touchMap = {
'tbtn-left': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); tetradogState.game.dasDir = -1; tetradogState.game.dasTimer = 0; moveTetraPiece(-1, 0); }, up: () => { if (tetradogState.game.dasDir === -1) tetradogState.game.dasDir = 0; } },
'tbtn-right': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); tetradogState.game.dasDir = 1; tetradogState.game.dasTimer = 0; moveTetraPiece(1, 0); }, up: () => { if (tetradogState.game.dasDir === 1) tetradogState.game.dasDir = 0; } },
'tbtn-down': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); tetradogState.game.softDropActive = true; }, up: () => { tetradogState.game.softDropActive = false; } },
'tbtn-up': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); else hardDropTetraPiece(); } },
'tbtn-harddrop': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); else hardDropTetraPiece(); } },
'tbtn-rot-cw': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); else rotateTetraPiece(1); } },
'tbtn-rot-ccw': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); else rotateTetraPiece(-1); } },
'tbtn-hold': { down: () => { if (tetradogState.game.status !== 'playing') startTetraDogGame(); else holdTetraPiece(); } },
'tbtn-pause': { down: () => { toggleTetraDogPlayPause(); } }
};
Object.entries(touchMap).forEach(([btnId, handlers]) => {
const el = document.getElementById(btnId);
if (!el) return;
el.addEventListener('touchstart', (e) => {
e.preventDefault();
if (handlers.down) handlers.down();
}, { passive: false });
if (handlers.up) {
el.addEventListener('touchend', (e) => {
e.preventDefault();
handlers.up();
}, { passive: false });
el.addEventListener('touchcancel', (e) => {
e.preventDefault();
handlers.up();
}, { passive: false });
}
el.addEventListener('mousedown', (e) => {
e.preventDefault();
if (handlers.down) handlers.down();
});
if (handlers.up) {
el.addEventListener('mouseup', (e) => {
e.preventDefault();
handlers.up();
});
}
});
}
// ---------------- LEADERBOARD & DB SYNCHRONIZATION ----------------
async function loadTetraDogLeaderboard(mode = 'marathon', showLoading = true) {
tetradogState.leaderboard.mode = mode;
const tbody = document.getElementById('tetradog-leaderboard-tbody');
// Update mode pills
document.querySelectorAll('.tetradog-lb-tab').forEach(b => {
b.classList.toggle('active', b.getAttribute('data-mode') === mode);
});
if (showLoading && tbody) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-dim); padding: 24px;">Fetching top scores from server...</td></tr>`;
}
const userOnly = tetradogState.leaderboard.scope === 'mine';
const url = `/api/chewtoys/tetradog/scores?mode=${encodeURIComponent(mode)}&user_only=${userOnly}&limit=50`;
try {
const resp = await fetch(url, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
const data = await resp.json();
tetradogState.leaderboard.list = data.leaderboard || [];
tetradogState.leaderboard.userStats = data.user_stats || null;
// Update personal top score in game
if (data.user_best) {
tetradogState.game.highScore = data.user_best.score;
updateTetraHeaderAndStats();
}
renderTetraDogLeaderboard(data);
} else {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--danger); padding: 20px;">Failed to load scores (${resp.status})</td></tr>`;
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--danger); padding: 20px;">Error connecting to leaderboard API: ${e}</td></tr>`;
}
}
function renderTetraDogLeaderboard(data) {
const tbody = document.getElementById('tetradog-leaderboard-tbody');
if (!tbody) return;
// Update Summary Banner
if (data.user_stats) {
const bestEl = document.getElementById('tetradog-sum-best');
const rankEl = document.getElementById('tetradog-sum-rank');
const lvlEl = document.getElementById('tetradog-sum-level');
const linesEl = document.getElementById('tetradog-sum-lines');
const gamesEl = document.getElementById('tetradog-sum-games');
if (bestEl) bestEl.textContent = data.user_stats.high_score.toLocaleString();
if (rankEl) rankEl.textContent = data.user_stats.best_rank ? `#${data.user_stats.best_rank}` : '-';
if (lvlEl) lvlEl.textContent = data.user_stats.max_level;
if (linesEl) linesEl.textContent = data.user_stats.total_lines.toLocaleString();
if (gamesEl) gamesEl.textContent = data.user_stats.games_played.toLocaleString();
}
// Populate Table Rows
const scores = data.leaderboard || [];
if (scores.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-dim); padding: 24px;">No high scores recorded yet in ${data.user_stats ? tetradogState.leaderboard.mode : ''} mode. Be the first!</td></tr>`;
return;
}
let html = '';
scores.forEach((row, idx) => {
let rankBadge = `${idx + 1}`;
if (idx === 0) rankBadge = `<span class="tetradog-rank-medal rank-gold">🥇</span>`;
else if (idx === 1) rankBadge = `<span class="tetradog-rank-medal rank-silver">🥈</span>`;
else if (idx === 2) rankBadge = `<span class="tetradog-rank-medal rank-bronze">🥉</span>`;
const rowClass = row.is_current_user ? 'current-user-row' : '';
const dateStr = row.created_at ? new Date(row.created_at).toLocaleDateString() : '-';
const durationStr = row.duration_seconds > 0 ? `${Math.floor(row.duration_seconds / 60)}m ${row.duration_seconds % 60}s` : '-';
html += `
<tr class="${rowClass}">
<td>${rankBadge}</td>
<td><strong>${escapeHtml(row.player_name || row.username)}</strong> ${row.is_current_user ? '<span class="badge" style="font-size: 8px; margin-left: 4px;">YOU</span>' : ''}</td>
<td style="text-align: right; font-weight: 800; color: var(--accent);">${row.score.toLocaleString()}</td>
<td style="text-align: right;">${row.level}</td>
<td style="text-align: right;">${row.lines_cleared}</td>
<td style="text-align: right; color: var(--text-dim); font-size: 10px;">${durationStr}</td>
<td style="text-align: right; color: var(--text-dim); font-size: 10px;">${dateStr}</td>
</tr>
`;
});
tbody.innerHTML = html;
}
function handleTetraDogScopeChange(scope) {
tetradogState.leaderboard.scope = scope;
loadTetraDogLeaderboard(tetradogState.leaderboard.mode);
}
function refreshTetraDogLeaderboard() {
loadTetraDogLeaderboard(tetradogState.leaderboard.mode);
}
function saveTetraDogPlayerAlias(alias) {
const clean = alias.trim();
tetradogState.playerAlias = clean;
localStorage.setItem('cd_tetradog_alias', clean);
showToast(`Player alias saved as: ${clean || App.user?.username || 'Player'}`, 'success');
}
async function submitTetraDogScore(score, lines, level, duration, mode) {
if (score <= 0) return;
const playerAlias = tetradogState.playerAlias || App.user?.nickname || App.user?.username || '';
try {
const resp = await fetch('/api/chewtoys/tetradog/scores', {
method: 'POST',
headers: {
'Authorization': `Bearer ${App.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
score,
lines_cleared: lines,
level,
duration_seconds: duration,
mode,
player_name: playerAlias
})
});
if (resp.ok) {
const result = await resp.json();
const rankBanner = document.getElementById('tetradog-rank-banner');
const rankText = document.getElementById('tetradog-rank-text');
if (rankBanner && rankText) {
if (result.is_global_high_score) {
rankText.textContent = `👑 NEW #1 ALL-TIME HIGH SCORE! (${score.toLocaleString()})`;
rankBanner.style.display = 'block';
} else if (result.is_personal_best) {
rankText.textContent = `⭐ Personal Best! Achieved Rank #${result.rank}`;
rankBanner.style.display = 'block';
} else {
rankText.textContent = `Achieved Rank #${result.rank} on Leaderboard`;
rankBanner.style.display = 'block';
}
}
// Update personal best in state
if (score > tetradogState.game.highScore) {
tetradogState.game.highScore = score;
updateTetraHeaderAndStats();
}
}
} catch (e) {
console.debug('Failed to submit TetraDog score:', e);
}
}
async function promptClearTetraDogScores() {
const confirmed = await showConfirmDialog({
title: 'Reset High Scores',
subtitle: 'Tetrion Arcade Records',
message: 'Are you sure you want to reset your Tetrion scores and personal best leaderboard records?',
icon: 'trash-2',
type: 'danger',
confirmText: 'Reset Scores'
});
if (!confirmed) return;
try {
const resp = await fetch('/api/tools/tetradog/scores', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (resp.ok) {
showToast('TetraDog scores cleared successfully', 'success');
tetradogState.game.highScore = 0;
updateTetraHeaderAndStats();
loadTetraDogLeaderboard(tetradogState.leaderboard.mode);
} else {
showToast(`Failed to clear scores: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Error: ${e}`, 'error');
}
}
// ---------------- IN-PANE DOCKED TETRADOG ----------------
function mountDockedTetraDog(paneIndex) {
const host = document.getElementById(`docked-tetradog-host-${paneIndex}`);
const arena = document.getElementById('tetradog-view-game');
if (!host || !arena) return;
// Move game arena into pane host
host.appendChild(arena);
arena.style.display = 'flex';
renderTetraCanvas();
}
// ==========================================================================
// 🎵 AUDIOPLAYER: MODERN UNIVERSAL AUDIO ENGINE CHEWTOY (FLOATING & DOCKED)
// ==========================================================================
const sounddogState = {
queue: [], // Array of { path, paneIndex, name, title, artist, album, duration, size, cover, bitrate, sampleRate, year, genre, fileObj }
currentIndex: -1,
selectedPlIndex: -1,
isPlaying: false,
isShuffle: false,
repeatMode: localStorage.getItem('cd_sounddog_repeat') || 'all', // 'off', 'all', 'one'
volume: parseFloat(localStorage.getItem('cd_sounddog_volume') || '0.8'),
isMuted: false,
playbackRate: 1.0,
timeMode: localStorage.getItem('cd_sounddog_timemode') || 'elapsed', // 'elapsed', 'remaining'
showEq: localStorage.getItem('cd_sounddog_show_eq') === '1',
showPl: (() => {
const isMobileOrTablet = typeof window !== 'undefined' && window.innerWidth <= 1024;
const saved = localStorage.getItem('cd_sounddog_show_pl');
return saved !== null ? (saved === '1') : (!isMobileOrTablet);
})(),
eqEnabled: true,
vizMode: localStorage.getItem('cd_sounddog_viz') || 'bars', // 'bars', 'wave', 'glow', 'off'
eq: {
preset: localStorage.getItem('cd_sounddog_eq_preset') || 'flat',
bands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000 Hz
preamp: 0
},
isMaximized: false,
initialized: false,
blobUrls: new Map(),
visPeakArray: new Array(24).fill(0),
visPeakDecay: new Array(24).fill(0)
};
let sounddogAudioCtx = null;
let sounddogSourceNode = null;
let sounddogAnalyserNode = null;
let sounddogGainNode = null;
let sounddogEqFilters = [];
let sounddogAnimFrame = null;
let sounddogDragInitialized = false;
const SOUNDDOG_EQ_FREQS = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000];
const SOUNDDOG_EQ_PRESETS = {
flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
bass_boost: [6.0, 5.0, 4.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0],
rock: [4.5, 3.0, 1.5, -1.0, -1.5, 1.0, 3.0, 4.5, 4.5, 4.5],
synthwave: [5.5, 4.0, 2.0, 0.0, -1.0, 1.5, 3.5, 4.5, 5.0, 5.0],
vocal: [-2.0, -1.0, 1.0, 3.5, 4.5, 3.5, 2.0, 0.5, -0.5, -2.0],
jazz: [3.5, 2.5, 1.5, 1.5, -1.0, -1.0, 0.0, 1.5, 2.5, 3.5],
classical: [4.0, 3.0, 2.0, 1.5, -1.0, -1.0, 0.0, 2.0, 3.5, 4.0],
pop: [-1.5, 1.5, 3.5, 4.5, 3.0, 0.0, -1.5, -1.5, -1.0, -1.0],
custom: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
};
// ============================================================================
// 🎵 SOUNDDOG CHIPTUNE & MIDI SYNTHESIZER ENGINE (#46)
// ============================================================================
const SoundDogSynthEngine = {
activeType: null, // 'midi', 'tracker', null
isPlaying: false,
currentTime: 0,
duration: 0,
timer: null,
activeNodes: [],
midiEvents: [],
onTimeUpdate: null,
onEnded: null,
getOutputNode() {
if (sounddogEqFilters && sounddogEqFilters.length > 0) return sounddogEqFilters[0];
if (sounddogAnalyserNode) return sounddogAnalyserNode;
if (sounddogAudioCtx) return sounddogAudioCtx.destination;
return null;
},
stop() {
this.isPlaying = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.activeNodes.forEach(n => {
try {
if (n.stop) n.stop();
n.disconnect();
} catch (_) {}
});
this.activeNodes = [];
this.activeType = null;
this.currentTime = 0;
this.duration = 0;
},
pause() {
this.isPlaying = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.activeNodes.forEach(n => {
try {
if (n.stop) n.stop();
n.disconnect();
} catch (_) {}
});
this.activeNodes = [];
},
async playMidi(arrayBuffer, ctx, outputNode, onTimeUpdate, onEnded) {
this.stop();
this.activeType = 'midi';
this.onTimeUpdate = onTimeUpdate;
this.onEnded = onEnded;
try {
const parsed = this.parseMidi(arrayBuffer);
if (!parsed || parsed.events.length === 0) {
throw new Error('No playable MIDI events found');
}
this.midiEvents = parsed.events;
this.duration = Math.max(1, parsed.duration);
this.currentTime = 0;
this.isPlaying = true;
const targetOutput = outputNode || this.getOutputNode();
const startTime = ctx.currentTime;
this.scheduleMidiEvents(ctx, targetOutput, startTime, 0);
const startWall = Date.now();
this.timer = setInterval(() => {
if (!this.isPlaying) return;
const elapsed = (Date.now() - startWall) / 1000;
this.currentTime = elapsed;
if (this.onTimeUpdate) {
this.onTimeUpdate(Math.min(this.duration, this.currentTime), this.duration);
}
if (this.currentTime >= this.duration) {
this.stop();
if (this.onEnded) this.onEnded();
}
}, 100);
return true;
} catch (err) {
console.warn('MIDI Synth playback notice:', err);
this.stop();
return false;
}
},
scheduleMidiEvents(ctx, outputNode, startCtxTime, fromTime = 0) {
const events = this.midiEvents.filter(e => e.time >= fromTime);
events.forEach(e => {
const triggerTime = startCtxTime + (e.time - fromTime);
if (triggerTime < ctx.currentTime) return;
if (e.type === 'noteOn') {
const dur = Math.max(0.1, e.duration || 0.4);
this.playMidiVoice(ctx, outputNode, e.channel, e.note, e.velocity, triggerTime, dur);
}
});
},
playMidiVoice(ctx, outputNode, channel, note, velocity, time, duration) {
if (velocity === 0 || !outputNode) return;
const vol = (velocity / 127) * 0.35;
const freq = 440 * Math.pow(2, (note - 69) / 12);
// Channel 9 (10 in 1-based GM) = Percussion / Drums
if (channel === 9) {
this.playMidiDrum(ctx, outputNode, note, vol, time);
return;
}
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
// Instrument waveform mapping
if (note < 48) {
osc.type = 'triangle'; // Warm bass
} else if (note > 72) {
osc.type = 'square'; // Chiptune lead
} else {
osc.type = 'sawtooth'; // Melodic body
}
osc.frequency.setValueAtTime(freq, time);
// ADSR Envelope
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(vol, time + 0.01);
gain.gain.exponentialRampToValueAtTime(vol * 0.6, time + 0.05);
gain.gain.setValueAtTime(vol * 0.6, time + duration * 0.7);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
osc.connect(gain);
gain.connect(outputNode);
osc.start(time);
osc.stop(time + duration + 0.05);
this.activeNodes.push(osc, gain);
} catch (_) {}
},
playMidiDrum(ctx, outputNode, note, vol, time) {
if (!outputNode) return;
try {
// Bass drum (35, 36)
if (note === 35 || note === 36) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(140, time);
osc.frequency.exponentialRampToValueAtTime(32, time + 0.15);
gain.gain.setValueAtTime(vol * 1.2, time);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.16);
osc.connect(gain);
gain.connect(outputNode);
osc.start(time);
osc.stop(time + 0.17);
this.activeNodes.push(osc, gain);
}
// Snare / Rimshot / Clap (37, 38, 39, 40)
else if (note >= 37 && note <= 40) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(220, time);
gain.gain.setValueAtTime(vol * 0.8, time);
gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.12);
osc.connect(gain);
gain.connect(outputNode);
osc.start(time);
osc.stop(time + 0.13);
this.activeNodes.push(osc, gain);
}
// Hi-Hats & Cymbals (42, 44, 46, 49, 51)
else {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(note > 48 ? 1200 : 800, time);
gain.gain.setValueAtTime(vol * 0.4, time);
gain.gain.exponentialRampToValueAtTime(0.0001, time + (note === 46 || note === 49 ? 0.35 : 0.06));
osc.connect(gain);
gain.connect(outputNode);
osc.start(time);
osc.stop(time + 0.4);
this.activeNodes.push(osc, gain);
}
} catch (_) {}
},
parseMidi(arrayBuffer) {
const data = new Uint8Array(arrayBuffer);
if (data.length < 14) return null;
if (data[0] !== 0x4d || data[1] !== 0x54 || data[2] !== 0x68 || data[3] !== 0x64) return null;
const format = (data[8] << 8) | data[9];
const ntracks = (data[10] << 8) | data[11];
let division = (data[12] << 8) | data[13];
if (division & 0x8000) division = 96;
let offset = 14;
const allEvents = [];
let currentTempo = 500000;
for (let t = 0; t < ntracks; t++) {
if (offset + 8 > data.length) break;
if (data[offset] !== 0x4d || data[offset+1] !== 0x54 || data[offset+2] !== 0x72 || data[offset+3] !== 0x6b) break;
const trackLen = (data[offset+4] << 24) | (data[offset+5] << 16) | (data[offset+6] << 8) | data[offset+7];
offset += 8;
const trackEnd = offset + trackLen;
let tick = 0;
let lastStatus = 0;
const noteOnMap = {};
while (offset < trackEnd && offset < data.length) {
let delta = 0;
let b = 0;
do {
b = data[offset++];
delta = (delta << 7) | (b & 0x7f);
} while (b & 0x80);
tick += delta;
const timeSec = (tick / division) * (currentTempo / 1000000);
let status = data[offset];
if (status < 0x80) {
status = lastStatus;
} else {
offset++;
lastStatus = status;
}
const msgType = status & 0xf0;
const channel = status & 0x0f;
if (status === 0xff) {
const metaType = data[offset++];
let metaLen = 0;
let mb = 0;
do {
mb = data[offset++];
metaLen = (metaLen << 7) | (mb & 0x7f);
} while (mb & 0x80);
if (metaType === 0x51 && metaLen === 3) {
currentTempo = (data[offset] << 16) | (data[offset+1] << 8) | data[offset+2];
}
offset += metaLen;
} else if (msgType === 0x90) {
const note = data[offset++];
const vel = data[offset++];
if (vel > 0) {
noteOnMap[`${channel}_${note}`] = { time: timeSec, vel, channel, note };
} else if (noteOnMap[`${channel}_${note}`]) {
const on = noteOnMap[`${channel}_${note}`];
allEvents.push({
type: 'noteOn',
channel: on.channel,
note: on.note,
velocity: on.vel,
time: on.time,
duration: Math.max(0.08, timeSec - on.time)
});
delete noteOnMap[`${channel}_${note}`];
}
} else if (msgType === 0x80) {
const note = data[offset++];
const vel = data[offset++];
if (noteOnMap[`${channel}_${note}`]) {
const on = noteOnMap[`${channel}_${note}`];
allEvents.push({
type: 'noteOn',
channel: on.channel,
note: on.note,
velocity: on.vel,
time: on.time,
duration: Math.max(0.08, timeSec - on.time)
});
delete noteOnMap[`${channel}_${note}`];
}
} else if (msgType === 0xc0) {
offset++;
} else if (msgType === 0xb0 || msgType === 0xe0) {
offset += 2;
} else if (msgType === 0xa0 || msgType === 0xd0) {
offset += (msgType === 0xa0 ? 2 : 1);
} else if (status === 0xf0 || status === 0xf7) {
while (offset < data.length && data[offset] !== 0xf7) offset++;
if (offset < data.length) offset++;
}
}
offset = trackEnd;
}
allEvents.sort((a, b) => a.time - b.time);
const maxTime = allEvents.length > 0 ? (allEvents[allEvents.length - 1].time + (allEvents[allEvents.length - 1].duration || 1)) : 60;
return { events: allEvents, duration: maxTime };
},
async playTracker(arrayBuffer, ext, ctx, outputNode, onTimeUpdate, onEnded) {
this.stop();
this.activeType = 'tracker';
this.onTimeUpdate = onTimeUpdate;
this.onEnded = onEnded;
try {
const data = new Uint8Array(arrayBuffer);
let numChannels = 4;
if (data.length >= 1084) {
const sig = String.fromCharCode(data[1080], data[1081], data[1082], data[1083]);
if (sig === '8CHN' || sig === 'OCTA') numChannels = 8;
}
const songLen = data[950] || 1;
const estimatedDuration = Math.max(30, songLen * 3.5);
this.duration = estimatedDuration;
this.currentTime = 0;
this.isPlaying = true;
const bpm = 125;
const speed = 6;
const rowDuration = (2.5 / bpm) * (speed / 6);
const totalRows = songLen * 64;
const patternOffset = 1084;
const noteEvents = [];
for (let r = 0; r < Math.min(totalRows, 1024); r++) {
const rowTime = r * rowDuration;
const patIdx = data[952 + Math.floor(r / 64)] || 0;
const patRow = r % 64;
const rowByteOffset = patternOffset + (patIdx * 64 * numChannels * 4) + (patRow * numChannels * 4);
if (rowByteOffset + (numChannels * 4) <= data.length) {
for (let ch = 0; ch < numChannels; ch++) {
const chOffset = rowByteOffset + (ch * 4);
const b0 = data[chOffset];
const b1 = data[chOffset + 1];
const b2 = data[chOffset + 2];
const b3 = data[chOffset + 3];
const period = ((b0 & 0x0f) << 8) | b1;
if (period > 0) {
const freq = 7093789.2 / (period * 2);
if (freq >= 40 && freq <= 5000) {
noteEvents.push({
time: rowTime,
freq: freq,
channel: ch,
duration: rowDuration * 2
});
}
}
}
}
}
const targetOutput = outputNode || this.getOutputNode();
const startTime = ctx.currentTime;
noteEvents.forEach(e => {
const t = startTime + e.time;
if (!targetOutput) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
if (e.channel % 2 === 0) osc.type = 'square';
else if (e.channel === 1) osc.type = 'sawtooth';
else osc.type = 'triangle';
osc.frequency.setValueAtTime(e.freq, t);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.2, t + 0.005);
gain.gain.exponentialRampToValueAtTime(0.0001, t + e.duration);
osc.connect(gain);
gain.connect(targetOutput);
osc.start(t);
osc.stop(t + e.duration + 0.02);
this.activeNodes.push(osc, gain);
} catch (_) {}
});
const startWall = Date.now();
this.timer = setInterval(() => {
if (!this.isPlaying) return;
const elapsed = (Date.now() - startWall) / 1000;
this.currentTime = elapsed;
if (this.onTimeUpdate) {
this.onTimeUpdate(Math.min(this.duration, this.currentTime), this.duration);
}
if (this.currentTime >= this.duration) {
this.stop();
if (this.onEnded) this.onEnded();
}
}, 100);
return true;
} catch (err) {
console.warn('Tracker Synth playback notice:', err);
this.stop();
return false;
}
}
};
function getSoundDogAudioElement() {
return document.getElementById('sounddog-audio-element');
}
function initSoundDogAudioEngine() {
const audioEl = getSoundDogAudioElement();
if (!audioEl) return;
if (!sounddogAudioCtx) {
try {
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
if (AudioCtxClass) {
sounddogAudioCtx = new AudioCtxClass();
sounddogSourceNode = sounddogAudioCtx.createMediaElementSource(audioEl);
// 10-Band Biquad Graphic Equalizer
sounddogEqFilters = SOUNDDOG_EQ_FREQS.map(freq => {
const filter = sounddogAudioCtx.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1.4;
filter.gain.value = 0;
return filter;
});
// Analyser Node for 60 FPS Visualizer
sounddogAnalyserNode = sounddogAudioCtx.createAnalyser();
sounddogAnalyserNode.fftSize = 256;
sounddogAnalyserNode.smoothingTimeConstant = 0.75;
// Master Volume Gain
sounddogGainNode = sounddogAudioCtx.createGain();
sounddogGainNode.gain.value = sounddogState.isMuted ? 0 : sounddogState.volume;
// Connect audio graph: source -> 10 EQ filters -> analyser -> master gain -> destination
let currentNode = sounddogSourceNode;
sounddogEqFilters.forEach(f => {
currentNode.connect(f);
currentNode = f;
});
currentNode.connect(sounddogAnalyserNode);
sounddogAnalyserNode.connect(sounddogGainNode);
sounddogGainNode.connect(sounddogAudioCtx.destination);
}
} catch (e) {
console.debug('Audioplayer Web Audio API notice:', e);
}
}
// HTML5 Audio Element Events
audioEl.ontimeupdate = () => {
updateSoundDogScrubber();
};
audioEl.onprogress = () => {
updateSoundDogBuffer();
};
audioEl.onplay = () => {
sounddogState.isPlaying = true;
updateSoundDogPlaybackState(true);
startSoundDogVisualizer();
};
audioEl.onpause = () => {
sounddogState.isPlaying = false;
updateSoundDogPlaybackState(false);
};
audioEl.onended = () => {
if (sounddogState.repeatMode === 'one') {
audioEl.currentTime = 0;
audioEl.play().catch(() => {});
} else {
playNextSoundDogTrack();
}
};
audioEl.onerror = () => {
if (sounddogState.queue.length > 0 && sounddogState.currentIndex >= 0) {
const cur = sounddogState.queue[sounddogState.currentIndex];
showToast(`Playback error for: ${cur.name}`, 'warning');
}
};
// Restore saved volume & EQ presets
setSoundDogVolume(sounddogState.volume);
if (sounddogState.eq.preset && SOUNDDOG_EQ_PRESETS[sounddogState.eq.preset]) {
applySoundDogEqPreset(sounddogState.eq.preset, false);
}
updateSoundDogRepeatButton();
updateSoundDogShuffleButton();
initSoundDogKeyboardShortcuts();
}
// ---------------- WINDOWING & DRAG/RESIZE ----------------
function openSoundDog(initialPath = null, paneIndex = null) {
closeToolsMenu();
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const win = document.getElementById('floating-sounddog-window');
const pill = document.getElementById('sounddog-pill');
if (pill) pill.style.display = 'none';
if (win) {
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
updateDynamicViewportHeight();
initSoundDogDragAndResize();
if (!sounddogState.initialized) {
initSoundDogAudioEngine();
sounddogState.initialized = true;
}
// Ensure playlist drawer is closed by default on phone & tablet viewports (<= 1024px) unless explicitly opened
const isCompactViewport = window.innerWidth <= 1024;
if (isCompactViewport && localStorage.getItem('cd_sounddog_show_pl') === null) {
sounddogState.showPl = false;
}
// Sync drawer visibility
const eqDrawer = document.getElementById('sounddog-eq-drawer');
const eqBtn = document.getElementById('btn-sounddog-eq-toggle');
if (eqDrawer) eqDrawer.style.display = sounddogState.showEq ? 'flex' : 'none';
if (eqBtn) eqBtn.classList.toggle('active', sounddogState.showEq);
const plDrawer = document.getElementById('sounddog-playlist-sidebar');
const plBtn = document.getElementById('btn-sounddog-playlist-toggle');
if (plDrawer) plDrawer.style.display = sounddogState.showPl ? 'flex' : 'none';
if (plBtn) plBtn.classList.toggle('active', sounddogState.showPl);
if (initialPath) {
addTracksToSoundDogQueue([initialPath], true, resolvedPaneIdx);
} else if (sounddogState.queue.length === 0) {
populateSoundDogFromActivePane(false, resolvedPaneIdx);
}
renderSoundDogQueue();
updateSoundDogQueueStats();
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
function openFloatingSoundDog() {
openSoundDog();
}
function dismissSoundDogPill() {
stopSoundDogPlay();
const pill = document.getElementById('sounddog-pill');
if (pill) pill.style.display = 'none';
}
function closeSoundDog(force = false) {
if (!force && sounddogState.isPlaying) {
minimizeFloatingSoundDog();
showToast('Audioplayer minimized to background pill', 'info');
return;
}
const win = document.getElementById('floating-sounddog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('sounddog-pill');
if (pill) pill.style.display = 'none';
if (force) {
stopSoundDogPlay();
}
}
function minimizeFloatingSoundDog() {
const win = document.getElementById('floating-sounddog-window');
if (win) win.style.display = 'none';
const pill = document.getElementById('sounddog-pill');
if (pill) {
pill.style.display = 'flex';
updateSoundDogPill();
updateSoundDogScrubber();
}
}
function restoreFloatingSoundDog() {
const pill = document.getElementById('sounddog-pill');
if (pill) pill.style.display = 'none';
openSoundDog();
}
function maximizeFloatingSoundDog() {
const win = document.getElementById('floating-sounddog-window');
if (!win) return;
sounddogState.isMaximized = !sounddogState.isMaximized;
win.classList.toggle('maximized', sounddogState.isMaximized);
}
function dockSoundDogToActivePane() {
dockToolToPane('sounddog', App.activePaneIndex);
}
function initSoundDogDragAndResize() {
if (sounddogDragInitialized) return;
sounddogDragInitialized = true;
const container = document.getElementById('floating-sounddog-window');
const header = document.getElementById('sounddog-header');
if (!container || !header) return;
// Restore saved position
const savedLeft = localStorage.getItem('cd_sounddog_x');
const savedTop = localStorage.getItem('cd_sounddog_y');
if (savedLeft && savedTop && window.innerWidth > 768) {
container.style.left = `${Math.min(window.innerWidth - 140, Math.max(0, parseInt(savedLeft, 10)))}px`;
container.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 768 || sounddogState.isMaximized) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
isDragging = true;
bringFloatingWindowToFront(container);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = container.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(30, Math.min(window.innerHeight - 60, winStartY + dy));
container.style.left = `${newX}px`;
container.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
const rect = container.getBoundingClientRect();
localStorage.setItem('cd_sounddog_x', Math.round(rect.left).toString());
localStorage.setItem('cd_sounddog_y', Math.round(rect.top).toString());
}
});
// Window Edge and Corner Resizing
const resizeHandles = [
{ el: document.getElementById('sounddog-resize-right'), dir: 'e' },
{ el: document.getElementById('sounddog-resize-bottom'), dir: 's' },
{ el: document.getElementById('sounddog-resize-corner'), dir: 'se' }
];
resizeHandles.forEach(({ el, dir }) => {
if (!el) return;
el.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 768 || sounddogState.isMaximized) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = container.offsetWidth;
const startH = container.offsetHeight;
const onMouseMove = (me) => {
if (dir.includes('e')) {
const newW = Math.max(320, Math.min(window.innerWidth - 20, startW + (me.clientX - startX)));
container.style.width = `${newW}px`;
}
if (dir.includes('s')) {
const newH = Math.max(200, Math.min(window.innerHeight - 40, startH + (me.clientY - startY)));
container.style.height = `${newH}px`;
}
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
});
// Drag & Drop files directly onto Audioplayer
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
container.classList.add('drag-over');
});
container.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
});
container.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
container.classList.remove('drag-over');
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleSoundDogDroppedFiles(e.dataTransfer.files);
}
});
}
// ---------------- DRAWER & CONTROLS TOGGLES ----------------
function toggleSoundDogPlaylistDrawer(forceState) {
if (typeof forceState === 'boolean') sounddogState.showPl = forceState;
else sounddogState.showPl = !sounddogState.showPl;
localStorage.setItem('cd_sounddog_show_pl', sounddogState.showPl ? '1' : '0');
const drawer = document.getElementById('sounddog-playlist-sidebar');
const btn = document.getElementById('btn-sounddog-playlist-toggle');
if (drawer) drawer.style.display = sounddogState.showPl ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', sounddogState.showPl);
}
function toggleSoundDogEqDrawer(forceState) {
if (typeof forceState === 'boolean') sounddogState.showEq = forceState;
else sounddogState.showEq = !sounddogState.showEq;
localStorage.setItem('cd_sounddog_show_eq', sounddogState.showEq ? '1' : '0');
const drawer = document.getElementById('sounddog-eq-drawer');
const btn = document.getElementById('btn-sounddog-eq-toggle');
if (drawer) drawer.style.display = sounddogState.showEq ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', sounddogState.showEq);
}
function cycleSoundDogRepeat() {
if (sounddogState.repeatMode === 'all') sounddogState.repeatMode = 'one';
else if (sounddogState.repeatMode === 'one') sounddogState.repeatMode = 'off';
else sounddogState.repeatMode = 'all';
localStorage.setItem('cd_sounddog_repeat', sounddogState.repeatMode);
updateSoundDogRepeatButton();
showToast(`Repeat: ${sounddogState.repeatMode.toUpperCase()}`, 'info');
}
function updateSoundDogRepeatButton() {
const btn = document.getElementById('btn-sounddog-repeat');
if (!btn) return;
const mode = sounddogState.repeatMode;
btn.classList.toggle('active', mode !== 'off');
btn.title = `Repeat Mode: ${mode.toUpperCase()} (R)`;
}
function toggleSoundDogShuffle() {
sounddogState.isShuffle = !sounddogState.isShuffle;
updateSoundDogShuffleButton();
showToast(`Shuffle: ${sounddogState.isShuffle ? 'ON' : 'OFF'}`, 'info');
}
function updateSoundDogShuffleButton() {
const btn = document.getElementById('btn-sounddog-shuffle');
if (btn) btn.classList.toggle('active', sounddogState.isShuffle);
}
function toggleSoundDogMute() {
sounddogState.isMuted = !sounddogState.isMuted;
const audioEl = getSoundDogAudioElement();
if (audioEl) audioEl.volume = sounddogState.isMuted ? 0 : sounddogState.volume;
if (sounddogGainNode && sounddogAudioCtx) {
sounddogGainNode.gain.setValueAtTime(sounddogState.isMuted ? 0 : sounddogState.volume, sounddogAudioCtx.currentTime);
}
const icon = document.getElementById('sounddog-volume-icon');
if (icon) {
icon.setAttribute('data-lucide', sounddogState.isMuted ? 'volume-x' : (sounddogState.volume > 0.5 ? 'volume-2' : 'volume-1'));
if (window.lucide) {
try { lucide.createIcons({ root: icon.parentElement }); } catch (e) {}
}
}
}
function setSoundDogVolume(val) {
const v = Math.max(0, Math.min(1, parseFloat(val)));
sounddogState.volume = v;
sounddogState.isMuted = false;
localStorage.setItem('cd_sounddog_volume', v.toString());
const audioEl = getSoundDogAudioElement();
if (audioEl) audioEl.volume = v;
if (sounddogGainNode && sounddogAudioCtx) {
sounddogGainNode.gain.setValueAtTime(v, sounddogAudioCtx.currentTime);
}
const slider = document.getElementById('sounddog-volume-slider');
if (slider && slider.value !== v.toString()) slider.value = v.toString();
const icon = document.getElementById('sounddog-volume-icon');
if (icon) {
icon.setAttribute('data-lucide', v === 0 ? 'volume-x' : (v > 0.5 ? 'volume-2' : 'volume-1'));
if (window.lucide) {
try { lucide.createIcons({ root: icon.parentElement }); } catch (e) {}
}
}
}
function skipSoundDog(secs) {
const audioEl = getSoundDogAudioElement();
if (!audioEl || !audioEl.duration) return;
audioEl.currentTime = Math.max(0, Math.min(audioEl.duration, audioEl.currentTime + secs));
updateSoundDogScrubber();
}
function handleSoundDogScrubberHover(e) {
const audioEl = getSoundDogAudioElement();
const trackEl = document.getElementById('sounddog-scrubber-track');
const tooltip = document.getElementById('sounddog-scrubber-tooltip');
if (!audioEl || !trackEl || !tooltip || !audioEl.duration) return;
const rect = trackEl.getBoundingClientRect();
const hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const pct = hoverX / rect.width;
const hoverSecs = pct * audioEl.duration;
tooltip.textContent = formatMediaTime(hoverSecs);
tooltip.style.left = `${hoverX}px`;
tooltip.style.display = 'block';
}
function hideSoundDogScrubberTooltip() {
const tooltip = document.getElementById('sounddog-scrubber-tooltip');
if (tooltip) tooltip.style.display = 'none';
}
function seekSoundDogFromEvent(e) {
const audioEl = getSoundDogAudioElement();
const trackEl = e.currentTarget || document.getElementById('sounddog-scrubber-track');
if (!audioEl || !trackEl || !audioEl.duration) return;
const rect = trackEl.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = Math.max(0, Math.min(1, clickX / rect.width));
audioEl.currentTime = pct * audioEl.duration;
updateSoundDogScrubber();
}
function seekSoundDogRelative(deltaSecs) {
skipSoundDog(deltaSecs);
}
function updateSoundDogBuffer() {
// Scrubber buffering feedback
}
function toggleSoundDogTimeMode() {
sounddogState.timeMode = (sounddogState.timeMode === 'elapsed') ? 'remaining' : 'elapsed';
localStorage.setItem('cd_sounddog_timemode', sounddogState.timeMode);
updateSoundDogScrubber();
}
// ---------------- QUEUE & PLAYLIST MANAGEMENT ----------------
function populateSoundDogFromActivePane(autoPlay = false, paneIndex = null) {
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[resolvedPaneIdx];
if (!pane || !pane.entries) return;
const audioEntries = pane.entries.filter(e => !e.is_dir && isAudioExtension(e.name));
if (audioEntries.length > 0) {
const paths = audioEntries.map(e => e.path);
addTracksToSoundDogQueue(paths, autoPlay, resolvedPaneIdx);
}
}
function addCurrentPaneFolderToSoundDog(paneIndex = null) {
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[resolvedPaneIdx];
if (!pane || !pane.entries) return;
const audioEntries = pane.entries.filter(e => !e.is_dir && isAudioExtension(e.name));
if (audioEntries.length === 0) {
showToast('No audio files found in current panel', 'info');
return;
}
const paths = audioEntries.map(e => e.path);
addTracksToSoundDogQueue(paths, sounddogState.queue.length === 0, resolvedPaneIdx);
showToast(`Added ${audioEntries.length} tracks to Audioplayer playlist`, 'success');
}
async function addDirectoryToSoundDog(dirPath, autoPlay = true, paneIndex = null) {
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
try {
const endpoint = getPaneEndpoint(resolvedPaneIdx);
const headers = getPaneAuthHeaders(resolvedPaneIdx);
const authUrl = resolveAuthUri(dirPath);
const url = `${endpoint}/api/fs/list?path=${encodeURIComponent(authUrl)}&show_hidden=false`;
const resp = await fetch(url, { headers });
if (resp.ok) {
const data = await resp.json();
const entries = data.entries || [];
const audioFiles = entries.filter(e => !e.is_dir && isAudioExtension(e.name)).map(e => e.path);
if (audioFiles.length > 0) {
openSoundDog();
addTracksToSoundDogQueue(audioFiles, autoPlay, resolvedPaneIdx);
showToast(`Loaded ${audioFiles.length} tracks into Audioplayer`, 'success');
} else {
showToast('No audio tracks found in folder', 'info');
}
}
} catch (e) {
console.error('Failed to load audio from directory:', e);
}
}
function addTracksToSoundDogQueue(paths, autoPlayFirst = false, paneIndex = null) {
if (!paths || paths.length === 0) return;
const resolvedPaneIdx = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const wasEmpty = sounddogState.queue.length === 0;
const newTracks = [];
paths.forEach(p => {
const exists = sounddogState.queue.some(t => t.path === p && (t.paneIndex === resolvedPaneIdx || t.paneIndex === undefined));
if (!exists) {
const fileName = p.split('/').pop() || p;
const cleanName = fileName.replace(/\.[^/.]+$/, '');
const parts = cleanName.split(' - ');
const artist = parts.length > 1 ? parts[0].trim() : '';
const title = parts.length > 1 ? parts.slice(1).join(' - ').trim() : cleanName;
newTracks.push({
path: p,
paneIndex: resolvedPaneIdx,
name: fileName,
title: title,
artist: artist,
album: 'Audioplayer Jukebox',
duration: 0,
size: 0,
cover: null,
bitrate: '320',
sampleRate: '44',
year: '',
genre: ''
});
}
});
sounddogState.queue.push(...newTracks);
renderSoundDogQueue();
updateSoundDogQueueStats();
if (wasEmpty || autoPlayFirst) {
const targetIdx = wasEmpty ? 0 : sounddogState.queue.length - newTracks.length;
loadSoundDogTrack(targetIdx, true);
}
}
function handleSoundDogDroppedFiles(fileList) {
const files = Array.from(fileList).filter(f => isAudioExtension(f.name) || f.type.startsWith('audio/'));
if (files.length === 0) {
showToast('No valid audio files dropped', 'info');
return;
}
const wasEmpty = sounddogState.queue.length === 0;
const newTracks = files.map(file => {
const blobUrl = URL.createObjectURL(file);
const cleanName = file.name.replace(/\.[^/.]+$/, '');
const parts = cleanName.split(' - ');
const artist = parts.length > 1 ? parts[0].trim() : '';
const title = parts.length > 1 ? parts.slice(1).join(' - ').trim() : cleanName;
return {
path: blobUrl,
name: file.name,
title: title,
artist: artist,
album: 'Local File',
duration: 0,
size: file.size,
cover: null,
bitrate: '320',
sampleRate: '44',
fileObj: file
};
});
sounddogState.queue.push(...newTracks);
renderSoundDogQueue();
updateSoundDogQueueStats();
if (wasEmpty) {
loadSoundDogTrack(0, true);
}
showToast(`Loaded ${newTracks.length} dropped audio tracks`, 'success');
}
function triggerSoundDogOpenFiles() {
const input = document.getElementById('sounddog-file-input');
if (input) input.click();
}
function handleSoundDogFilePicker(e) {
if (e.target && e.target.files && e.target.files.length > 0) {
handleSoundDogDroppedFiles(e.target.files);
e.target.value = '';
}
}
function clearSoundDogQueue() {
const audioEl = getSoundDogAudioElement();
if (audioEl) {
audioEl.pause();
audioEl.src = '';
}
sounddogState.queue = [];
sounddogState.currentIndex = -1;
sounddogState.selectedPlIndex = -1;
sounddogState.isPlaying = false;
updateSoundDogPlaybackState(false);
resetSoundDogHUD();
renderSoundDogQueue();
updateSoundDogQueueStats();
}
function removeSoundDogTrack(index) {
if (index < 0 || index >= sounddogState.queue.length) return;
const isCurrent = index === sounddogState.currentIndex;
sounddogState.queue.splice(index, 1);
if (sounddogState.queue.length === 0) {
clearSoundDogQueue();
return;
}
if (isCurrent) {
const nextIdx = Math.min(index, sounddogState.queue.length - 1);
loadSoundDogTrack(nextIdx, sounddogState.isPlaying);
} else if (index < sounddogState.currentIndex) {
sounddogState.currentIndex--;
}
sounddogState.selectedPlIndex = Math.min(sounddogState.selectedPlIndex, sounddogState.queue.length - 1);
renderSoundDogQueue();
updateSoundDogQueueStats();
}
function removeSoundDogSelectedTrack() {
const targetIdx = sounddogState.selectedPlIndex >= 0 ? sounddogState.selectedPlIndex : sounddogState.currentIndex;
if (targetIdx >= 0) {
removeSoundDogTrack(targetIdx);
}
}
function randomizeSoundDogQueue() {
if (sounddogState.queue.length <= 1) return;
const currentTrack = sounddogState.currentIndex >= 0 ? sounddogState.queue[sounddogState.currentIndex] : null;
// Fisher-Yates Shuffle
for (let i = sounddogState.queue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sounddogState.queue[i], sounddogState.queue[j]] = [sounddogState.queue[j], sounddogState.queue[i]];
}
if (currentTrack) {
sounddogState.currentIndex = sounddogState.queue.indexOf(currentTrack);
}
renderSoundDogQueue();
showToast('Playlist randomized', 'info');
}
function renderSoundDogQueue() {
const tbody = document.getElementById('sounddog-queue-tbody');
const filterInput = document.getElementById('sounddog-queue-filter');
const filterTerm = (filterInput?.value || '').toLowerCase().trim();
if (!tbody) return;
if (sounddogState.queue.length === 0) {
tbody.innerHTML = `
<div class="audioplayer-playlist-empty">
<p style="font-weight: 600; color: var(--text-muted); margin-bottom: 4px;">Playlist is empty</p>
<small style="opacity: 0.7;">Double-click audio files in panels or click + to load</small>
</div>
`;
return;
}
let html = '';
sounddogState.queue.forEach((track, idx) => {
if (filterTerm) {
const match = (track.title || '').toLowerCase().includes(filterTerm) ||
(track.artist || '').toLowerCase().includes(filterTerm) ||
(track.name || '').toLowerCase().includes(filterTerm);
if (!match) return;
}
const isCurrent = idx === sounddogState.currentIndex;
const isSelected = idx === sounddogState.selectedPlIndex;
const durStr = track.duration > 0 ? formatMediaTime(track.duration) : '--:--';
const displayTitle = track.artist ? `${track.artist} - ${track.title}` : (track.title || track.name);
html += `
<div class="audioplayer-pl-row ${isCurrent ? 'playing' : ''} ${isSelected ? 'selected' : ''}"
onclick="selectSoundDogPlTrack(${idx})"
ondblclick="loadSoundDogTrack(${idx}, true)">
<span class="audioplayer-pl-num">${idx + 1}.</span>
<span class="audioplayer-pl-title" title="${escapeHtml(displayTitle)}">${escapeHtml(displayTitle)}</span>
<span class="audioplayer-pl-time">${durStr}</span>
<button type="button" class="audioplayer-pl-remove" onclick="event.stopPropagation(); removeSoundDogTrack(${idx})" title="Remove Track">✕</button>
</div>
`;
});
tbody.innerHTML = html;
}
function selectSoundDogPlTrack(index) {
sounddogState.selectedPlIndex = index;
renderSoundDogQueue();
}
function filterSoundDogQueue(term) {
renderSoundDogQueue();
}
function updateSoundDogQueueStats() {
const countBadge = document.getElementById('sounddog-playlist-count');
const count = sounddogState.queue.length;
if (countBadge) countBadge.textContent = count.toString();
}
function updateSoundDogScrubber() {
const isSynth = SoundDogSynthEngine.activeType !== null;
const audioEl = getSoundDogAudioElement();
const cur = isSynth ? SoundDogSynthEngine.currentTime : (audioEl?.currentTime || 0);
const dur = isSynth ? SoundDogSynthEngine.duration : (audioEl?.duration || 0);
const pct = dur > 0 ? (cur / dur) * 100 : 0;
const progEl = document.getElementById('sounddog-scrubber-progress');
const thumbEl = document.getElementById('sounddog-scrubber-thumb');
const timeCur = document.getElementById('sounddog-time-current');
const timeTot = document.getElementById('sounddog-time-total');
const timeBadge = document.getElementById('sounddog-track-time-badge');
if (progEl) progEl.style.width = `${pct}%`;
if (thumbEl) thumbEl.style.left = `${pct}%`;
if (sounddogState.timeMode === 'remaining' && dur > 0) {
const rem = Math.max(0, dur - cur);
if (timeCur) timeCur.textContent = `-${formatMediaTime(rem)}`;
} else {
if (timeCur) timeCur.textContent = formatMediaTime(cur);
}
if (timeTot) timeTot.textContent = dur > 0 ? formatMediaTime(dur) : '00:00';
if (timeBadge) timeBadge.textContent = dur > 0 ? formatMediaTime(dur) : '00:00';
// Sync docked SoundDog progress and time displays
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'sounddog') {
const dProg = document.getElementById(`docked-sounddog-scrubber-progress-${idx}`);
const dCur = document.getElementById(`docked-sounddog-time-cur-${idx}`);
const dTot = document.getElementById(`docked-sounddog-time-tot-${idx}`);
if (dProg) dProg.style.width = `${pct}%`;
if (dCur) dCur.textContent = formatMediaTime(cur);
if (dTot) dTot.textContent = dur > 0 ? formatMediaTime(dur) : '00:00';
}
});
// Sync sounddog minimized pill progress and time display
const pillTime = document.getElementById('sounddog-pill-time');
if (pillTime) pillTime.textContent = `${formatMediaTime(cur)} / ${dur > 0 ? formatMediaTime(dur) : '00:00'}`;
const pillProg = document.getElementById('sounddog-pill-progress-fill');
if (pillProg) pillProg.style.width = `${pct}%`;
if (dur > 0 && sounddogState.currentIndex >= 0 && sounddogState.queue[sounddogState.currentIndex]) {
const track = sounddogState.queue[sounddogState.currentIndex];
if (!track.duration) {
track.duration = dur;
updateSoundDogQueueStats();
renderSoundDogQueue();
}
}
}
function updateSoundDogBuffer() {
// Scrubber buffering feedback
}
function toggleSoundDogTimeMode() {
sounddogState.timeMode = (sounddogState.timeMode === 'elapsed') ? 'remaining' : 'elapsed';
localStorage.setItem('cd_sounddog_timemode', sounddogState.timeMode);
updateSoundDogScrubber();
}
// ---------------- AUDIO PLAYBACK & TRACK LOADING ----------------
async function loadSoundDogTrack(index, autoPlay = true) {
if (index < 0 || index >= sounddogState.queue.length) return;
sounddogState.currentIndex = index;
sounddogState.selectedPlIndex = index;
const track = sounddogState.queue[index];
const audioEl = getSoundDogAudioElement();
if (!audioEl) return;
if (!sounddogAudioCtx) {
initSoundDogAudioEngine();
}
if (sounddogAudioCtx && sounddogAudioCtx.state === 'suspended') {
try { await sounddogAudioCtx.resume(); } catch (e) {}
}
const ext = (track.name.split('.').pop() || '').toLowerCase();
const isSynthTrack = isTrackerOrMidiExtension(track.name);
updateSoundDogHUD(track);
renderSoundDogQueue();
updateSoundDogPill();
updateSoundDogDockedHUD();
let streamUrl = track.path;
let clientFileObj = track.fileObj || null;
if (!clientFileObj && track.path.startsWith('client://')) {
try {
const handle = await resolveClientFileHandle(track.path, false);
if (handle) {
clientFileObj = await handle.getFile();
track.fileObj = clientFileObj;
streamUrl = URL.createObjectURL(clientFileObj);
}
} catch (e) {}
} else if (!clientFileObj) {
streamUrl = getDownloadUrl(track.path, true, track.paneIndex);
}
if (isSynthTrack) {
audioEl.pause();
audioEl.src = '';
SoundDogSynthEngine.stop();
try {
let arrayBuffer;
if (clientFileObj) {
arrayBuffer = await clientFileObj.arrayBuffer();
} else {
const authHeaders = (track.paneIndex !== undefined && track.paneIndex !== null) ? getPaneAuthHeaders(track.paneIndex) : {};
const resp = await fetch(streamUrl, { headers: { ...authHeaders } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
arrayBuffer = await resp.arrayBuffer();
}
const onTimeUpdate = (cur, dur) => {
const pct = dur > 0 ? (cur / dur) * 100 : 0;
const progEl = document.getElementById('sounddog-scrubber-progress');
const thumbEl = document.getElementById('sounddog-scrubber-thumb');
const timeCur = document.getElementById('sounddog-time-current');
const timeTot = document.getElementById('sounddog-time-total');
const timeBadge = document.getElementById('sounddog-track-time-badge');
if (progEl) progEl.style.width = `${pct}%`;
if (thumbEl) thumbEl.style.left = `${pct}%`;
if (timeCur) timeCur.textContent = formatMediaTime(cur);
if (timeTot) timeTot.textContent = formatMediaTime(dur);
if (timeBadge) timeBadge.textContent = formatMediaTime(dur);
App.panes.forEach((p, idx) => {
if (p && p.dockedTool === 'sounddog') {
const dProg = document.getElementById(`docked-sounddog-scrubber-progress-${idx}`);
const dCur = document.getElementById(`docked-sounddog-time-cur-${idx}`);
const dTot = document.getElementById(`docked-sounddog-time-tot-${idx}`);
if (dProg) dProg.style.width = `${pct}%`;
if (dCur) dCur.textContent = formatMediaTime(cur);
if (dTot) dTot.textContent = formatMediaTime(dur);
}
});
};
const onEnded = () => {
if (sounddogState.repeatMode === 'one') {
loadSoundDogTrack(sounddogState.currentIndex, true);
} else {
playNextSoundDogTrack();
}
};
const outNode = SoundDogSynthEngine.getOutputNode();
let success = false;
if (ext === 'mid' || ext === 'midi') {
success = await SoundDogSynthEngine.playMidi(arrayBuffer, sounddogAudioCtx, outNode, onTimeUpdate, onEnded);
} else {
success = await SoundDogSynthEngine.playTracker(arrayBuffer, ext, sounddogAudioCtx, outNode, onTimeUpdate, onEnded);
}
if (success) {
sounddogState.isPlaying = true;
updateSoundDogPlaybackState(true);
startSoundDogVisualizer();
}
} catch (err) {
console.warn('Synth playback failed:', err);
showToast(`Chiptune engine notice: ${err.message}`, 'warning');
}
} else {
SoundDogSynthEngine.stop();
audioEl.src = streamUrl;
audioEl.playbackRate = sounddogState.playbackRate;
if (autoPlay) {
audioEl.play().catch(e => {
console.debug('Audioplayer playback notice:', e);
});
}
if (!track.fileObj) {
parseSoundDogMetadata(track);
}
}
}
function toggleSoundDogPlay() {
if (SoundDogSynthEngine.activeType) {
if (SoundDogSynthEngine.isPlaying) {
SoundDogSynthEngine.pause();
sounddogState.isPlaying = false;
updateSoundDogPlaybackState(false);
} else {
if (sounddogAudioCtx && sounddogAudioCtx.state === 'suspended') {
sounddogAudioCtx.resume().catch(() => {});
}
SoundDogSynthEngine.isPlaying = true;
sounddogState.isPlaying = true;
updateSoundDogPlaybackState(true);
startSoundDogVisualizer();
}
return;
}
const audioEl = getSoundDogAudioElement();
if (!audioEl) return;
if (sounddogAudioCtx && sounddogAudioCtx.state === 'suspended') {
sounddogAudioCtx.resume().catch(() => {});
}
if (sounddogState.currentIndex === -1 && sounddogState.queue.length > 0) {
loadSoundDogTrack(0, true);
return;
}
if (audioEl.paused) {
audioEl.play().catch(() => {});
} else {
audioEl.pause();
}
}
function stopSoundDogPlay() {
SoundDogSynthEngine.stop();
const audioEl = getSoundDogAudioElement();
if (audioEl) {
audioEl.pause();
audioEl.currentTime = 0;
}
sounddogState.isPlaying = false;
updateSoundDogPlaybackState(false);
updateSoundDogScrubber();
}
function playNextSoundDogTrack() {
if (sounddogState.queue.length === 0) return;
let nextIdx = 0;
if (sounddogState.isShuffle) {
nextIdx = Math.floor(Math.random() * sounddogState.queue.length);
if (nextIdx === sounddogState.currentIndex && sounddogState.queue.length > 1) {
nextIdx = (nextIdx + 1) % sounddogState.queue.length;
}
} else {
if (sounddogState.currentIndex + 1 >= sounddogState.queue.length) {
if (sounddogState.repeatMode === 'off') {
stopSoundDogPlay();
return;
}
nextIdx = 0;
} else {
nextIdx = sounddogState.currentIndex + 1;
}
}
loadSoundDogTrack(nextIdx, true);
}
function playPrevSoundDogTrack() {
if (sounddogState.queue.length === 0) return;
if (SoundDogSynthEngine.activeType && SoundDogSynthEngine.currentTime > 3) {
SoundDogSynthEngine.currentTime = 0;
return;
}
const audioEl = getSoundDogAudioElement();
if (audioEl && audioEl.currentTime > 3) {
audioEl.currentTime = 0;
return;
}
let prevIdx = sounddogState.currentIndex - 1;
if (prevIdx < 0) {
prevIdx = sounddogState.queue.length - 1;
}
loadSoundDogTrack(prevIdx, true);
}
function updateSoundDogPlaybackState(isPlaying) {
sounddogState.isPlaying = isPlaying;
const playIcon = document.getElementById('sounddog-play-icon');
const pillPlayIcon = document.getElementById('sounddog-pill-play-icon');
const pill = document.getElementById('sounddog-pill');
const wave = document.getElementById('sounddog-pill-wave');
if (playIcon) {
playIcon.setAttribute('data-lucide', isPlaying ? 'pause' : 'play');
}
if (pillPlayIcon) {
pillPlayIcon.setAttribute('data-lucide', isPlaying ? 'pause' : 'play');
}
if (pill) {
pill.classList.toggle('playing', !!isPlaying);
}
if (wave) {
wave.classList.toggle('playing', !!isPlaying);
}
// If playing in background with floating window closed and not docked, show pill
const win = document.getElementById('floating-sounddog-window');
if (pill) {
const isFloatingOpen = win && win.style.display !== 'none';
const isDocked = App.panes && App.panes.some(p => p && p.dockedTool === 'sounddog');
if (isPlaying && !isFloatingOpen && !isDocked) {
pill.style.display = 'flex';
updateSoundDogPill();
updateSoundDogScrubber();
}
}
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
updateSoundDogDockedHUD();
if (typeof updateAppDocumentTitle === 'function') {
updateAppDocumentTitle();
}
}
function updateSoundDogHUD(track) {
if (!track) return;
const headerTitle = document.getElementById('sounddog-header-title');
const trackTitle = document.getElementById('sounddog-track-title');
const trackArtist = document.getElementById('sounddog-track-artist');
const formatBadge = document.getElementById('sounddog-format-badge');
const kbpsPill = document.getElementById('sounddog-tech-bitrate');
const khzPill = document.getElementById('sounddog-tech-samplerate');
const idxNum = sounddogState.currentIndex + 1;
const displayTitle = track.artist ? `${track.artist} - ${track.title}` : (track.title || track.name);
const ext = (track.name.split('.').pop() || 'AUDIO').toUpperCase();
const isSynth = isTrackerOrMidiExtension(track.name);
if (headerTitle) headerTitle.textContent = `${idxNum}. ${displayTitle}`;
if (trackTitle) trackTitle.textContent = track.title || track.name;
if (trackArtist) trackArtist.textContent = track.artist || (isSynth ? 'Chiptune Synthesizer' : 'Audioplayer Jukebox');
if (typeof updateAppDocumentTitle === 'function') {
updateAppDocumentTitle();
}
if (formatBadge) formatBadge.textContent = ext;
if (kbpsPill) kbpsPill.textContent = `${track.bitrate || '320'} KBPS`;
if (khzPill) khzPill.textContent = `${track.sampleRate || '44.1'} KHZ`;
// Media Session metadata for OS lockscreen & hardware keys
if ('mediaSession' in navigator) {
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || track.name,
artist: track.artist || 'Audioplayer',
album: track.album || 'Audioplayer Jukebox',
artwork: [{ src: track.cover || 'assets/amber-media.webp', sizes: '128x128', type: 'image/webp' }]
});
navigator.mediaSession.setActionHandler('play', () => toggleSoundDogPlay());
navigator.mediaSession.setActionHandler('pause', () => toggleSoundDogPlay());
navigator.mediaSession.setActionHandler('previoustrack', () => playPrevSoundDogTrack());
navigator.mediaSession.setActionHandler('nexttrack', () => playNextSoundDogTrack());
} catch (e) {}
}
}
function resetSoundDogHUD() {
const headerTitle = document.getElementById('sounddog-header-title');
const trackTitle = document.getElementById('sounddog-track-title');
const trackArtist = document.getElementById('sounddog-track-artist');
const timeCur = document.getElementById('sounddog-time-current');
const timeTot = document.getElementById('sounddog-time-total');
const timeBadge = document.getElementById('sounddog-track-time-badge');
if (headerTitle) headerTitle.textContent = 'No audio loaded';
if (trackTitle) trackTitle.textContent = 'Audioplayer';
if (trackArtist) trackArtist.textContent = 'Woofsons Lab Jukebox';
if (timeCur) timeCur.textContent = '00:00';
if (timeTot) timeTot.textContent = '00:00';
if (timeBadge) timeBadge.textContent = '00:00';
const progEl = document.getElementById('sounddog-scrubber-progress');
const thumbEl = document.getElementById('sounddog-scrubber-thumb');
if (progEl) progEl.style.width = '0%';
if (thumbEl) thumbEl.style.left = '0%';
}
function updateSoundDogPill() {
const pillTitle = document.getElementById('sounddog-pill-title');
const pillPlayIcon = document.getElementById('sounddog-pill-play-icon');
const pill = document.getElementById('sounddog-pill');
const wave = document.getElementById('sounddog-pill-wave');
if (pillTitle) {
if (sounddogState.currentIndex >= 0 && sounddogState.queue[sounddogState.currentIndex]) {
const cur = sounddogState.queue[sounddogState.currentIndex];
pillTitle.textContent = cur.artist ? `${cur.artist} - ${cur.title}` : (cur.title || cur.name);
} else {
pillTitle.textContent = 'Audioplayer';
}
}
if (pillPlayIcon) {
pillPlayIcon.setAttribute('data-lucide', sounddogState.isPlaying ? 'pause' : 'play');
}
if (pill) {
pill.classList.toggle('playing', !!sounddogState.isPlaying);
}
if (wave) {
wave.classList.toggle('playing', !!sounddogState.isPlaying);
}
if (window.lucide) {
try { lucide.createIcons(); } catch (e) {}
}
}
function updateSoundDogDockedHUD() {
App.panes.forEach((p, idx) => {
if (p.dockedTool === 'sounddog') {
const btn = document.getElementById(`docked-sounddog-play-btn-${idx}`);
if (btn) {
btn.setAttribute('data-lucide', sounddogState.isPlaying ? 'pause' : 'play');
if (window.lucide) {
try { lucide.createIcons({ root: btn.parentElement }); } catch (e) {}
}
}
const titleEl = document.getElementById(`docked-sounddog-track-${idx}`);
if (titleEl && sounddogState.currentIndex >= 0 && sounddogState.queue[sounddogState.currentIndex]) {
const cur = sounddogState.queue[sounddogState.currentIndex];
titleEl.textContent = cur.artist ? `${cur.artist} - ${cur.title}` : (cur.title || cur.name);
}
}
});
}
// ---------------- 60 FPS REAL-TIME VISUALIZER ----------------
function cycleSoundDogVizMode() {
const modes = ['bars', 'wave', 'glow', 'off'];
const curIdx = modes.indexOf(sounddogState.vizMode);
const nextMode = modes[(curIdx + 1) % modes.length];
sounddogState.vizMode = nextMode;
localStorage.setItem('cd_sounddog_viz', nextMode);
showToast(`Visualizer: ${nextMode.toUpperCase()}`, 'info');
if (nextMode === 'off') {
const canvas = document.getElementById('sounddog-visualizer-canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
} else {
startSoundDogVisualizer();
}
}
function startSoundDogVisualizer() {
if (sounddogAnimFrame) cancelAnimationFrame(sounddogAnimFrame);
const canvas = document.getElementById('sounddog-visualizer-canvas');
if (!canvas || sounddogState.vizMode === 'off') return;
const ctx = canvas.getContext('2d');
const bufferLength = sounddogAnalyserNode ? sounddogAnalyserNode.frequencyBinCount : 128;
const dataArray = new Uint8Array(bufferLength);
function renderVisualizer() {
if (!sounddogState.isPlaying || sounddogState.vizMode === 'off') {
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
sounddogAnimFrame = requestAnimationFrame(renderVisualizer);
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
if (sounddogState.vizMode === 'bars') {
sounddogAnalyserNode.getByteFrequencyData(dataArray);
const barCount = 20;
const barWidth = Math.max(2, Math.floor((width - (barCount * 2)) / barCount));
const barGap = 2;
const step = Math.floor(bufferLength / barCount);
for (let i = 0; i < barCount; i++) {
const val = dataArray[i * step] / 255;
const barHeight = Math.max(1, Math.floor(val * (height - 2)));
const x = i * (barWidth + barGap) + 2;
// Falling Peak Indicator
if (barHeight >= (sounddogState.visPeakArray[i] || 0)) {
sounddogState.visPeakArray[i] = barHeight;
sounddogState.visPeakDecay[i] = 0;
} else {
sounddogState.visPeakDecay[i] = (sounddogState.visPeakDecay[i] || 0) + 0.2;
sounddogState.visPeakArray[i] = Math.max(0, sounddogState.visPeakArray[i] - sounddogState.visPeakDecay[i]);
}
// Draw segmented LED bar
for (let segY = height - 1; segY >= height - barHeight; segY -= 3) {
const normY = (height - segY) / height;
if (normY > 0.8) ctx.fillStyle = '#ef4444'; // Red peak
else if (normY > 0.55) ctx.fillStyle = '#fbbf24'; // Amber
else ctx.fillStyle = '#f59e0b'; // Amber Charcoal accent
ctx.fillRect(x, segY, barWidth, 2);
}
// Draw falling peak cap
const peakY = height - Math.floor(sounddogState.visPeakArray[i] || 0);
if (peakY < height && peakY >= 0) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(x, peakY, barWidth, 1.5);
}
}
} else if (sounddogState.vizMode === 'wave') {
sounddogAnalyserNode.getByteTimeDomainData(dataArray);
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#f59e0b';
ctx.shadowColor = 'rgba(245, 158, 11, 0.8)';
ctx.shadowBlur = 6;
ctx.beginPath();
const sliceWidth = width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * height) / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
} else if (sounddogState.vizMode === 'glow') {
sounddogAnalyserNode.getByteFrequencyData(dataArray);
let avg = 0;
for (let i = 0; i < 16; i++) avg += dataArray[i];
avg = (avg / 16) / 255;
const grad = ctx.createRadialGradient(width / 2, height / 2, 1, width / 2, height / 2, width / 2);
grad.addColorStop(0, `rgba(245, 158, 11, ${avg * 0.9})`);
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
}
}
renderVisualizer();
}
// ---------------- 10-BAND GRAPHIC EQUALIZER CONTROLS ----------------
function applySoundDogEqPreset(presetName, updateSelect = true) {
const gains = SOUNDDOG_EQ_PRESETS[presetName] || SOUNDDOG_EQ_PRESETS.flat;
sounddogState.eq.preset = presetName;
localStorage.setItem('cd_sounddog_eq_preset', presetName);
gains.forEach((gain, idx) => {
setSoundDogEqBand(idx, gain, false);
});
if (updateSelect) {
const sel = document.getElementById('sounddog-eq-preset-select');
if (sel) sel.value = presetName;
}
}
function setSoundDogEqBand(bandIdx, dbValue, isCustom = true) {
const val = parseFloat(dbValue);
sounddogState.eq.bands[bandIdx] = val;
if (sounddogEqFilters[bandIdx] && sounddogAudioCtx && sounddogState.eqEnabled) {
sounddogEqFilters[bandIdx].gain.setValueAtTime(val, sounddogAudioCtx.currentTime);
}
const slider = document.getElementById(`sounddog-eq-band-${bandIdx}`);
const dbLabel = document.getElementById(`sounddog-eq-db-${bandIdx}`);
if (slider) slider.value = val.toString();
if (dbLabel) dbLabel.textContent = `${val > 0 ? '+' : ''}${val.toFixed(0)}`;
if (isCustom) {
sounddogState.eq.preset = 'custom';
const sel = document.getElementById('sounddog-eq-preset-select');
if (sel) sel.value = 'custom';
}
}
function toggleSoundDogEqEnabled() {
sounddogState.eqEnabled = !sounddogState.eqEnabled;
const btn = document.getElementById('btn-sounddog-eq-power');
if (btn) {
btn.textContent = sounddogState.eqEnabled ? 'ON' : 'OFF';
btn.classList.toggle('btn-accent', sounddogState.eqEnabled);
btn.classList.toggle('btn-outline', !sounddogState.eqEnabled);
}
if (sounddogAudioCtx) {
sounddogEqFilters.forEach((f, idx) => {
const targetGain = sounddogState.eqEnabled ? sounddogState.eq.bands[idx] : 0;
f.gain.setValueAtTime(targetGain, sounddogAudioCtx.currentTime);
});
}
}
// ---------------- ID3 METADATA PARSER ----------------
async function parseSoundDogMetadata(track) {
if (!track || !track.path) return;
try {
const streamUrl = getDownloadUrl(track.path, true, track.paneIndex);
const authHeaders = (track.paneIndex !== undefined && track.paneIndex !== null) ? getPaneAuthHeaders(track.paneIndex) : {};
const resp = await fetch(streamUrl, {
headers: { 'Range': 'bytes=0-131071', ...authHeaders }
});
if (resp.ok || resp.status === 206) {
const buffer = await resp.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
let offset = 10;
const tagSize = ((bytes[6] & 0x7f) << 21) | ((bytes[7] & 0x7f) << 14) | ((bytes[8] & 0x7f) << 7) | (bytes[9] & 0x7f);
while (offset < Math.min(bytes.length - 10, tagSize)) {
const frameId = String.fromCharCode(bytes[offset], bytes[offset+1], bytes[offset+2], bytes[offset+3]);
if (!/^[A-Z0-9]{4}$/.test(frameId)) break;
const frameSize = (bytes[offset+4] << 24) | (bytes[offset+5] << 16) | (bytes[offset+6] << 8) | bytes[offset+7];
if (frameSize <= 0 || offset + 10 + frameSize > bytes.length) break;
const frameData = bytes.slice(offset + 10, offset + 10 + frameSize);
if (frameId === 'TIT2') {
track.title = decodeId3String(frameData);
} else if (frameId === 'TPE1') {
track.artist = decodeId3String(frameData);
} else if (frameId === 'TALB') {
track.album = decodeId3String(frameData);
} else if (frameId === 'TYER' || frameId === 'TDRC') {
track.year = decodeId3String(frameData);
} else if (frameId === 'TCON') {
track.genre = decodeId3String(frameData);
}
offset += 10 + frameSize;
}
}
}
} catch (e) {
console.debug('Audioplayer ID3 parse notice:', e);
}
if (sounddogState.currentIndex >= 0 && sounddogState.queue[sounddogState.currentIndex]?.path === track.path) {
updateSoundDogHUD(track);
renderSoundDogQueue();
}
}
function decodeId3String(bytes) {
if (!bytes || bytes.length === 0) return '';
const encoding = bytes[0];
const strBytes = bytes.slice(1);
try {
if (encoding === 0) return new TextDecoder('iso-8859-1').decode(strBytes).replace(/\0/g, '').trim();
if (encoding === 1) return new TextDecoder('utf-16').decode(strBytes).replace(/\0/g, '').trim();
if (encoding === 2) return new TextDecoder('utf-16be').decode(strBytes).replace(/\0/g, '').trim();
return new TextDecoder('utf-8').decode(strBytes).replace(/\0/g, '').trim();
} catch (e) {
return new TextDecoder().decode(strBytes).replace(/\0/g, '').trim();
}
}
function exportSoundDogM3U() {
if (sounddogState.queue.length === 0) {
showToast('Playlist is empty, nothing to export', 'info');
return;
}
let m3u = '#EXTM3U\n';
sounddogState.queue.forEach(t => {
m3u += `#EXTINF:${Math.round(t.duration || -1)},${t.artist ? `${t.artist} - ` : ''}${t.title || t.name}\n`;
m3u += `${t.path}\n`;
});
const blob = new Blob([m3u], { type: 'audio/x-mpegurl' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Audioplayer_Playlist_${new Date().toISOString().slice(0, 10)}.m3u`;
a.click();
URL.revokeObjectURL(url);
showToast('Playlist exported (.m3u)', 'success');
}
// ---------------- KEYBOARD SHORTCUTS ----------------
function initSoundDogKeyboardShortcuts() {
window.addEventListener('keydown', (e) => {
const win = document.getElementById('floating-sounddog-window');
const isSoundDogOpen = win && win.style.display !== 'none';
if (!isSoundDogOpen) return;
// Ignore if typing inside text fields
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
// Alt + Hotkeys
if (e.altKey) {
if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
toggleSoundDogPlaylistDrawer();
return;
}
}
// Playback Hotkeys (Space, Z, X, C, V, B, L, S, R, E, M)
const key = e.key.toLowerCase();
switch (key) {
case ' ':
case 'x':
case 'c':
e.preventDefault();
toggleSoundDogPlay();
break;
case 'z':
e.preventDefault();
playPrevSoundDogTrack();
break;
case 'v':
e.preventDefault();
stopSoundDogPlay();
break;
case 'b':
e.preventDefault();
playNextSoundDogTrack();
break;
case 'l':
e.preventDefault();
triggerSoundDogOpenFiles();
break;
case 's':
e.preventDefault();
toggleSoundDogShuffle();
break;
case 'r':
e.preventDefault();
cycleSoundDogRepeat();
break;
case 'e':
e.preventDefault();
toggleSoundDogEqDrawer();
break;
case 'm':
e.preventDefault();
toggleSoundDogMute();
break;
case 'arrowleft':
e.preventDefault();
skipSoundDog(-10);
break;
case 'arrowright':
e.preventDefault();
skipSoundDog(10);
break;
case 'arrowup':
e.preventDefault();
setSoundDogVolume(sounddogState.volume + 0.05);
break;
case 'arrowdown':
e.preventDefault();
setSoundDogVolume(sounddogState.volume - 0.05);
break;
case 'delete':
e.preventDefault();
removeSoundDogSelectedTrack();
break;
}
});
}
// ---------------- DOCKED SOUNDDOG PANE ----------------
function mountDockedSoundDog(paneIndex) {
const host = document.getElementById(`docked-sounddog-host-${paneIndex}`);
if (!host) return;
const currentTrack = (sounddogState.currentIndex >= 0 && sounddogState.queue[sounddogState.currentIndex])
? sounddogState.queue[sounddogState.currentIndex]
: null;
const displayTitle = currentTrack
? (currentTrack.artist ? `${currentTrack.artist} - ${currentTrack.title}` : (currentTrack.title || currentTrack.name))
: 'No audio loaded';
const ext = currentTrack ? (currentTrack.name.split('.').pop() || 'AUDIO').toUpperCase() : 'AUDIO';
const isSynth = currentTrack ? isTrackerOrMidiExtension(currentTrack.name) : false;
host.innerHTML = `
<div style="display: flex; flex-direction: column; width: 100%; height: 100%; gap: 6px; overflow: hidden;">
<!-- Now Playing Card -->
<div style="background: var(--bg-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; flex-shrink: 0;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;">
<div style="font-size: 11.5px; font-weight: 700; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" id="docked-sounddog-track-${paneIndex}">
${escapeHtml(displayTitle)}
</div>
<div style="font-size: 10px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; margin-top: 2px;">
<span class="badge" id="docked-sounddog-badge-${paneIndex}" style="font-size: 8.5px; padding: 1px 4px; text-transform: uppercase;">${escapeHtml(ext)}</span>
<span id="docked-sounddog-artist-${paneIndex}" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(currentTrack?.artist || (isSynth ? 'Chiptune Synthesizer' : 'Woofsons Lab'))}</span>
</div>
</div>
<!-- Mini Wave Visualizer -->
<div class="sounddog-pill-wave" id="docked-sounddog-wave-${paneIndex}" style="height: 14px; opacity: ${sounddogState.isPlaying ? '1' : '0.4'};">
<span></span><span></span><span></span><span></span>
</div>
</div>
<!-- Scrubber Progress Bar & Time Display -->
<div style="display: flex; flex-direction: column; gap: 2px;">
<div class="audioplayer-scrubber-track" id="docked-sounddog-scrubber-track-${paneIndex}" style="height: 4px; background: rgba(255,255,255,0.12); border-radius: 2px; width: 100%; position: relative; cursor: pointer; overflow: hidden;" onclick="seekSoundDogFromEvent(event)">
<div class="audioplayer-scrubber-progress" id="docked-sounddog-scrubber-progress-${paneIndex}" style="height: 100%; background: var(--accent); width: 0%;"></div>
</div>
<div style="display: flex; justify-content: space-between; font-size: 9.5px; font-family: var(--font-mono); color: var(--text-muted);">
<span id="docked-sounddog-time-cur-${paneIndex}">00:00</span>
<span id="docked-sounddog-time-tot-${paneIndex}">00:00</span>
</div>
</div>
<!-- Transport Action Toolbar (26px buttons per Rule 9) -->
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; padding-top: 2px;">
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-icon btn-panel-control" onclick="playPrevSoundDogTrack()" title="Previous Track"><i data-lucide="skip-back"></i></button>
<button class="btn btn-icon btn-panel-control btn-accent" onclick="toggleSoundDogPlay()" title="Play / Pause"><i data-lucide="${sounddogState.isPlaying ? 'pause' : 'play'}" id="docked-sounddog-play-btn-${paneIndex}"></i></button>
<button class="btn btn-icon btn-panel-control" onclick="playNextSoundDogTrack()" title="Next Track"><i data-lucide="skip-forward"></i></button>
<button class="btn btn-icon btn-panel-control" onclick="stopSoundDogPlay()" title="Stop"><i data-lucide="square"></i></button>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-icon btn-panel-control ${sounddogState.isShuffle ? 'active' : ''}" onclick="toggleSoundDogShuffle()" title="Toggle Shuffle"><i data-lucide="shuffle"></i></button>
<button class="btn btn-icon btn-panel-control ${sounddogState.repeatMode !== 'off' ? 'active' : ''}" onclick="cycleSoundDogRepeat()" title="Toggle Repeat"><i data-lucide="${sounddogState.repeatMode === 'one' ? 'repeat-1' : 'repeat'}"></i></button>
<button class="btn btn-icon btn-panel-control" onclick="triggerSoundDogOpenFiles()" title="Add / Load Audio Files"><i data-lucide="plus"></i></button>
</div>
</div>
</div>
<!-- Compact Queue List -->
<div style="font-size: 10px; font-weight: 700; color: var(--text-dim); display: flex; justify-content: space-between; align-items: center; padding: 2px 2px 0 2px; flex-shrink: 0;">
<span>PLAYLIST QUEUE (${sounddogState.queue.length})</span>
<button class="btn btn-xs" onclick="clearSoundDogQueue()" style="font-size: 9px; padding: 1px 5px;">Clear</button>
</div>
<div style="flex: 1; overflow-y: auto; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; display: flex; flex-direction: column; gap: 2px;" id="docked-sounddog-queue-${paneIndex}">
${sounddogState.queue.length === 0 ? `
<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 11px;">
Playlist empty. Double click audio files or load a folder.
</div>
` : sounddogState.queue.map((t, idx) => `
<div class="audioplayer-pl-row ${idx === sounddogState.currentIndex ? 'playing' : ''}"
onclick="loadSoundDogTrack(${idx}, true)"
style="padding: 4px 6px; font-size: 10.5px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 6px; overflow: hidden; flex: 1;">
<span class="audioplayer-pl-num" style="font-family: var(--font-mono); font-size: 9.5px; opacity: 0.6; min-width: 14px;">${idx + 1}.</span>
<span class="audioplayer-pl-title" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(t.artist ? `${t.artist} - ${t.title}` : (t.title || t.name))}</span>
</div>
<span class="audioplayer-pl-time" style="font-size: 9.5px; font-family: var(--font-mono); opacity: 0.7; margin-left: 6px;">${t.duration > 0 ? formatMediaTime(t.duration) : '--:--'}</span>
</div>
`).join('')}
</div>
</div>
`;
if (window.lucide) {
try { lucide.createIcons({ root: host }); } catch (e) {}
}
}
// ============================================================================
// 🐶 COMMANDER FLEET: MULTI-HOST NODE SWITCHER & CLUSTER DIAGNOSTICS (#24)
// ============================================================================
const FLEET_STORAGE_KEY = 'cd_fleet_nodes';
const FLEET_ACTIVE_KEY = 'cd_active_fleet_node_id';
function loadFleetNodes() {
try {
const raw = localStorage.getItem(FLEET_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('Failed to load fleet nodes from storage:', e);
return [];
}
}
function saveFleetNodes(nodes, skipServerSync = false) {
try {
localStorage.setItem(FLEET_STORAGE_KEY, JSON.stringify(nodes || []));
if (!skipServerSync && typeof queueSaveUserPreferencesToServer === 'function') {
queueSaveUserPreferencesToServer();
}
} catch (e) {
console.error('Failed to save fleet nodes to storage:', e);
}
}
function getAllFleetNodes() {
return loadFleetNodes();
}
function getFleetNodeById(id) {
if (!id) return null;
const nodes = getAllFleetNodes();
return nodes.find(n => n.id === id) || null;
}
function getActiveFleetNodeId() {
return localStorage.getItem(FLEET_ACTIVE_KEY) || null;
}
function getActiveFleetNode() {
const activeId = getActiveFleetNodeId();
if (!activeId) return null;
return getFleetNodeById(activeId);
}
// Ping a single node's /api/health endpoint
async function pingFleetNode(nodeId) {
let nodes = loadFleetNodes();
let node = nodes.find(n => n.id === nodeId);
if (!node && nodeId !== 'local') return null;
const endpoint = nodeId === 'local'
? window.location.origin
: (node.endpoint_url || '').trim().replace(/\/+$/, '');
if (!endpoint) return null;
const url = `${endpoint}/api/health`;
const headers = {};
if (node && node.auth_token && node.auth_token.trim()) {
headers['Authorization'] = `Bearer ${node.auth_token.trim()}`;
} else if (nodeId === 'local' && App.token) {
headers['Authorization'] = `Bearer ${App.token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
const t0 = performance.now();
try {
const resp = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
cache: 'no-store'
});
clearTimeout(timeoutId);
const latency = Math.round(performance.now() - t0);
if (resp.ok) {
const data = await resp.json();
if (node) {
node.status = 'online';
node.latency_ms = latency;
node.version = data.version || null;
node.hostname = data.hostname || data.node_name || null;
node.os = data.os || null;
node.arch = data.arch || null;
node.last_seen = Date.now();
saveFleetNodes(nodes, true);
}
return { status: 'online', latency_ms: latency, data, node };
} else if (resp.status === 401 || resp.status === 403) {
if (node) {
node.status = 'unauthorized';
node.latency_ms = latency;
node.last_seen = Date.now();
saveFleetNodes(nodes, true);
}
return { status: 'unauthorized', latency_ms: latency, node };
} else {
if (node) {
node.status = 'offline';
node.latency_ms = null;
saveFleetNodes(nodes, true);
}
return { status: 'offline', latency_ms: null, node };
}
} catch (err) {
clearTimeout(timeoutId);
if (node) {
node.status = 'offline';
node.latency_ms = null;
saveFleetNodes(nodes, true);
}
return { status: 'offline', latency_ms: null, error: err.message, node };
} finally {
renderFleetSwitcherDropdown();
const managerModal = document.getElementById('fleet-manager-modal');
if (managerModal && managerModal.classList.contains('active')) {
renderFleetManagerList();
}
}
}
// Ping all registered fleet nodes concurrently
async function pingAllFleetNodes() {
const nodes = loadFleetNodes();
const promises = nodes.map(n => pingFleetNode(n.id));
await Promise.allSettled(promises);
renderFleetSwitcherDropdown();
const managerModal = document.getElementById('fleet-manager-modal');
if (managerModal && managerModal.classList.contains('active')) {
renderFleetManagerList();
}
}
// Switch active pane to fleet node profile
function switchToFleetNode(nodeId) {
if (!nodeId || nodeId === 'local') {
localStorage.removeItem(FLEET_ACTIVE_KEY);
switchPaneNode(App.activePane ?? 0, 'local');
showToast('Switched active pane to Local Host', 'success');
closeFleetSwitcherDropdown();
renderFleetSwitcherDropdown();
updateHostnameBadge();
return;
}
const node = getFleetNodeById(nodeId);
if (!node) {
showToast('Fleet node not found', 'error');
return;
}
localStorage.setItem(FLEET_ACTIVE_KEY, nodeId);
switchPaneNode(App.activePane ?? 0, nodeId);
showToast(`Switched active pane to ${node.name}`, 'success');
closeFleetSwitcherDropdown();
renderFleetSwitcherDropdown();
updateHostnameBadge();
}
// Open remote node standalone Web UI in external browser tab
function openFleetNodeWebUi(nodeId, event) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const node = getFleetNodeById(nodeId);
if (!node || !node.endpoint_url) return;
const cleanUrl = node.endpoint_url.replace(/\/+$/, '');
let target = cleanUrl;
if (node.auth_token && node.auth_token.trim()) {
target += `/#token=${encodeURIComponent(node.auth_token.trim())}`;
}
window.open(target, '_blank', 'noopener,noreferrer');
}
// Dropdown UI
function toggleFleetSwitcherDropdown(event) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const dropdown = document.getElementById('fleet-switcher-dropdown');
const badge = document.getElementById('header-hostname-badge');
if (!dropdown) return;
const isShown = dropdown.style.display === 'block';
if (isShown) {
closeFleetSwitcherDropdown();
} else {
renderFleetSwitcherDropdown();
dropdown.style.display = 'block';
if (badge) badge.classList.add('dropdown-active');
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: dropdown });
}
} catch (_) {}
pingAllFleetNodes();
}
}
function closeFleetSwitcherDropdown() {
const dropdown = document.getElementById('fleet-switcher-dropdown');
const badge = document.getElementById('header-hostname-badge');
if (dropdown) dropdown.style.display = 'none';
if (badge) badge.classList.remove('dropdown-active');
}
function getFleetNodeVersionBadge(nodeVer, localVer = App.version) {
if (!nodeVer) {
return `<span class="fleet-tag-pill" style="font-family: var(--font-mono); font-size: 8.5px; opacity: 0.55; padding: 0 4px;" title="Version unprobed / unknown">v?</span>`;
}
const cleanNode = String(nodeVer).trim().replace(/^v/, '');
const cleanLocal = (localVer ? String(localVer).trim().replace(/^v/, '') : '');
if (!cleanLocal || cleanNode === cleanLocal) {
return `<span class="fleet-tag-pill" style="font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; background: rgba(16, 185, 129, 0.12); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.25);" title="Version match: v${escapeHtml(cleanNode)}">v${escapeHtml(cleanNode)}</span>`;
} else {
return `<span class="fleet-tag-pill" style="font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.35);" title="Version mismatch: Node runs v${escapeHtml(cleanNode)}, coordinator is v${escapeHtml(cleanLocal)}. Update node for full compatibility.">v${escapeHtml(cleanNode)} ⚠️</span>`;
}
}
function getFleetColorHex(name) {
switch (name) {
case 'emerald': return '#10b981';
case 'sky': return '#0284c7';
case 'purple': return '#a855f7';
case 'rose': return '#f43f5e';
case 'cyan': return '#06b6d4';
case 'slate': return '#64748b';
case 'amber':
default: return '#f59e0b';
}
}
function renderFleetSwitcherDropdown() {
const container = document.getElementById('fleet-dropdown-nodes-list');
const activeTag = document.getElementById('fleet-active-node-tag');
if (!container) return;
const activeId = getActiveFleetNodeId();
const nodes = getAllFleetNodes();
const activeNode = activeId ? nodes.find(n => n.id === activeId) : null;
if (activeTag) {
activeTag.textContent = activeNode ? activeNode.name : getLocalHostDisplayName();
}
let html = '';
// 1. Local Node
const isLocalActive = !activeId || activeId === 'local';
html += `
<div class="fleet-node-item ${isLocalActive ? 'active' : ''}" onclick="switchToFleetNode('local')">
<div class="fleet-node-left">
<i data-lucide="server" style="width: 14px; height: 14px; color: var(--accent); flex-shrink: 0;"></i>
<div class="fleet-node-info">
<div class="fleet-node-name">${escapeHtml(getLocalHostDisplayName())} ${isLocalActive ? '<span class="fleet-tag-pill" style="font-size: 9px; padding: 1px 4px;">Active</span>' : ''}</div>
<div class="fleet-node-url">${escapeHtml(window.location.host || 'localhost')} • <span style="color: var(--text-muted); font-family: var(--font-mono); font-size: 9.5px;">v${escapeHtml(App.version || '1.1.2')}</span></div>
</div>
</div>
<div class="fleet-node-right">
<span class="fleet-ping-dot green" title="Local node"></span>
<span class="fleet-node-latency">0ms</span>
</div>
</div>
`;
// 2. Registered Nodes
nodes.forEach(n => {
const isAct = n.id === activeId;
let dotClass = 'gray';
let latencyText = '—';
if (n.status === 'online') {
dotClass = (n.latency_ms && n.latency_ms < 120) ? 'green' : (n.latency_ms && n.latency_ms < 300 ? 'amber' : 'green');
latencyText = n.latency_ms ? `${n.latency_ms}ms` : 'online';
} else if (n.status === 'unauthorized') {
dotClass = 'amber';
latencyText = '401/403';
} else if (n.status === 'offline') {
dotClass = 'red';
latencyText = 'offline';
}
const colorHex = getFleetColorHex(n.color_accent);
html += `
<div class="fleet-node-item ${isAct ? 'active' : ''}" onclick="switchToFleetNode('${escapeHtml(n.id)}')">
<div class="fleet-node-left">
<i data-lucide="network" style="width: 14px; height: 14px; color: ${colorHex}; flex-shrink: 0;"></i>
<div class="fleet-node-info">
<div class="fleet-node-name">${escapeHtml(n.name)} ${isAct ? '<span class="fleet-tag-pill" style="font-size: 9px; padding: 1px 4px;">Active</span>' : ''}</div>
<div class="fleet-node-url">${escapeHtml(n.endpoint_url || '')}</div>
</div>
</div>
<div class="fleet-node-right" style="display: flex; align-items: center; gap: 6px;">
${getFleetNodeVersionBadge(n.version)}
<span class="fleet-ping-dot ${dotClass}" title="${n.status || 'unknown'}"></span>
<span class="fleet-node-latency">${latencyText}</span>
<button type="button" class="btn btn-xs btn-ghost" onclick="openFleetNodeWebUi('${escapeHtml(n.id)}', event)" title="Open ${escapeHtml(n.name)} Web UI in new tab" style="height: 20px; width: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center; opacity: 0.7;">
<i data-lucide="external-link" style="width: 11px; height: 11px;"></i>
</button>
</div>
</div>
`;
});
container.innerHTML = html;
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: container });
}
} catch (_) {}
}
// Fleet Manager Modal
function openFleetManagerModal() {
closeFleetSwitcherDropdown();
renderFleetManagerList();
resetFleetNodeForm();
showModal('fleet-manager-modal');
pingAllFleetNodes();
}
function openFleetAddModal() {
openFleetManagerModal();
resetFleetNodeForm();
setTimeout(() => {
const input = document.getElementById('fleet-input-name');
if (input) input.focus();
}, 100);
}
function renderFleetManagerList() {
const container = document.getElementById('fleet-manager-nodes-list');
const countBadge = document.getElementById('fleet-node-count-badge');
if (!container) return;
const nodes = getAllFleetNodes();
const activeId = getActiveFleetNodeId();
const currentEditingId = document.getElementById('fleet-input-id')?.value || '';
if (countBadge) {
const totalCount = nodes.length + 1; // +1 for local host
countBadge.textContent = `${totalCount} Node${totalCount === 1 ? '' : 's'}`;
}
let html = '';
// Local Host Card
const isLocalActive = !activeId || activeId === 'local';
html += `
<div class="fleet-card ${isLocalActive ? 'active' : ''}" style="border-left: 3px solid #f59e0b;" onclick="resetFleetNodeForm()">
<div class="fleet-card-header">
<div class="fleet-card-name">
<i data-lucide="server" style="width: 14px; height: 14px; color: #f59e0b;"></i>
<span>${escapeHtml(getLocalHostDisplayName())}</span>
<span class="fleet-tag-pill" style="font-size: 9px; padding: 1px 4px; background: rgba(245, 158, 11, 0.15); color: var(--accent);">Local</span>
<span class="fleet-tag-pill" style="font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; background: rgba(16, 185, 129, 0.12); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.25);" title="Local coordinator: v${escapeHtml(App.version || '1.1.2')}">v${escapeHtml(App.version || '1.1.2')}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<span class="fleet-ping-dot green" title="Local node"></span>
<span style="font-size: 10.5px; font-family: var(--font-mono); color: var(--text-dim);">0ms</span>
</div>
</div>
<div class="fleet-card-url">${escapeHtml(window.location.origin || 'http://localhost:3140')}</div>
<div class="fleet-card-footer">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<span class="fleet-tag-pill">builtin</span>
<span class="fleet-tag-pill">origin</span>
</div>
<div style="display: flex; gap: 4px;">
${!isLocalActive ? `<button type="button" class="btn btn-xs btn-outline" onclick="event.stopPropagation(); switchToFleetNode('local');" style="height: 22px; font-size: 10px; padding: 0 6px;">Set Active</button>` : `<span style="font-size: 10px; color: var(--accent); font-weight: 600;">Active</span>`}
</div>
</div>
</div>
`;
// Registered Node Cards
nodes.forEach(n => {
const isAct = n.id === activeId;
const isEditing = n.id === currentEditingId;
const colorHex = getFleetColorHex(n.color_accent);
let dotClass = 'gray';
let latencyText = '—';
if (n.status === 'online') {
dotClass = (n.latency_ms && n.latency_ms < 120) ? 'green' : (n.latency_ms && n.latency_ms < 300 ? 'amber' : 'green');
latencyText = n.latency_ms ? `${n.latency_ms}ms` : 'online';
} else if (n.status === 'unauthorized') {
dotClass = 'amber';
latencyText = '401/403';
} else if (n.status === 'offline') {
dotClass = 'red';
latencyText = 'offline';
}
const tagsHtml = (n.tags || []).map(t => `<span class="fleet-tag-pill">${escapeHtml(t)}</span>`).join('');
html += `
<div class="fleet-card ${isAct ? 'active' : ''}" style="border-left: 3px solid ${colorHex}; ${isEditing ? 'outline: 1.5px solid var(--accent);' : ''}" onclick="selectFleetNodeInManager('${escapeHtml(n.id)}')">
<div class="fleet-card-header">
<div class="fleet-card-name">
<i data-lucide="network" style="width: 14px; height: 14px; color: ${colorHex};"></i>
<span>${escapeHtml(n.name)}</span>
${getFleetNodeVersionBadge(n.version)}
${isAct ? '<span class="fleet-tag-pill" style="font-size: 9px; padding: 1px 4px; background: rgba(245, 158, 11, 0.15); color: var(--accent);">Active</span>' : ''}
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<span class="fleet-ping-dot ${dotClass}" title="${n.status || 'unknown'}"></span>
<span style="font-size: 10.5px; font-family: var(--font-mono); color: var(--text-dim);">${latencyText}</span>
</div>
</div>
<div class="fleet-card-url">${escapeHtml(n.endpoint_url || '')}</div>
<div class="fleet-card-footer">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
${tagsHtml || '<span class="fleet-tag-pill" style="opacity: 0.6;">no tags</span>'}
</div>
<div style="display: flex; gap: 4px;">
<button type="button" class="btn btn-xs btn-outline" onclick="event.stopPropagation(); openFleetNodeWebUi('${escapeHtml(n.id)}', event)" title="Open Node Web UI in New Tab" style="height: 22px; width: 22px; padding: 0;"><i data-lucide="external-link" style="width: 11px; height: 11px;"></i></button>
<button type="button" class="btn btn-xs btn-outline" onclick="event.stopPropagation(); pingFleetNode('${escapeHtml(n.id)}')" title="Ping Node" style="height: 22px; width: 22px; padding: 0;"><i data-lucide="activity" style="width: 11px; height: 11px;"></i></button>
<button type="button" class="btn btn-xs btn-outline" onclick="event.stopPropagation(); deleteFleetNodeProfile('${escapeHtml(n.id)}', event)" title="Delete Node" style="height: 22px; width: 22px; padding: 0; color: #ef4444;"><i data-lucide="trash-2" style="width: 11px; height: 11px;"></i></button>
${!isAct ? `<button type="button" class="btn btn-xs btn-outline" onclick="event.stopPropagation(); switchToFleetNode('${escapeHtml(n.id)}');" style="height: 22px; font-size: 10px; padding: 0 6px;">Set Active</button>` : `<span style="font-size: 10px; color: var(--accent); font-weight: 600;">Active</span>`}
</div>
</div>
</div>
`;
});
container.innerHTML = html;
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: container });
}
} catch (_) {}
}
function selectFleetNodeInManager(id) {
const node = getFleetNodeById(id);
if (!node) return;
const idInput = document.getElementById('fleet-input-id');
const nameInput = document.getElementById('fleet-input-name');
const urlInput = document.getElementById('fleet-input-url');
const tokenInput = document.getElementById('fleet-input-token');
const colorInput = document.getElementById('fleet-input-color');
const tagsInput = document.getElementById('fleet-input-tags');
if (idInput) idInput.value = node.id || '';
if (nameInput) nameInput.value = node.name || '';
if (urlInput) urlInput.value = node.endpoint_url || '';
if (tokenInput) tokenInput.value = node.auth_token || '';
if (colorInput) colorInput.value = node.color_accent || 'amber';
if (tagsInput) tagsInput.value = (node.tags || []).join(', ');
const titleEl = document.getElementById('fleet-editor-title');
if (titleEl) titleEl.textContent = `Edit: ${node.name}`;
const badgeEl = document.getElementById('fleet-editor-badge');
if (badgeEl) badgeEl.textContent = 'Configured Node';
selectFleetColor(node.color_accent || 'amber');
const results = document.getElementById('fleet-test-results');
if (results) {
if (node.status === 'online') {
const cleanNode = node.version ? String(node.version).trim().replace(/^v/, '') : '';
const cleanLocal = App.version ? String(App.version).trim().replace(/^v/, '') : '';
const isMismatch = cleanNode && cleanLocal && cleanNode !== cleanLocal;
let mismatchBanner = '';
if (isMismatch) {
mismatchBanner = `
<div style="margin-top: 4px; padding: 4px 8px; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 4px; color: #f59e0b; font-size: 10.5px; display: flex; align-items: center; gap: 6px;">
<i data-lucide="alert-triangle" style="width: 13px; height: 13px; flex-shrink: 0;"></i>
<span>Version mismatch: Node runs <strong>v${escapeHtml(cleanNode)}</strong> while coordinator is <strong>v${escapeHtml(cleanLocal)}</strong>. Features (such as archive formats or APIs) may differ.</span>
</div>
`;
}
results.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 2px;">
<span style="color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 4px;">
<i data-lucide="check-circle-2" style="width: 12px; height: 12px;"></i> Last Seen Online: ${node.latency_ms || 0}ms latency
</span>
<span style="font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; flex-wrap: wrap;">
<span>Host: <strong style="color: var(--text-main);">${escapeHtml(node.hostname || 'unknown')}</strong></span> •
<span>Version: ${getFleetNodeVersionBadge(node.version)}</span>
</span>
${mismatchBanner}
</div>
`;
} else if (node.status === 'unauthorized') {
results.innerHTML = `<span style="color: #f59e0b;"><i data-lucide="alert-triangle" style="width: 12px; height: 12px;"></i> Node reached but returned 401/403 Unauthorized. Check token.</span>`;
} else if (node.status === 'offline') {
results.innerHTML = `<span style="color: #ef4444;"><i data-lucide="x-circle" style="width: 12px; height: 12px;"></i> Node unreachable or offline.</span>`;
} else {
results.innerHTML = `<span>Click "Test Connection" to query node health, version, and latency.</span>`;
}
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: results });
}
} catch (_) {}
}
renderFleetManagerList();
}
function resetFleetNodeForm() {
const idInput = document.getElementById('fleet-input-id');
const nameInput = document.getElementById('fleet-input-name');
const urlInput = document.getElementById('fleet-input-url');
const tokenInput = document.getElementById('fleet-input-token');
const colorInput = document.getElementById('fleet-input-color');
const tagsInput = document.getElementById('fleet-input-tags');
if (idInput) idInput.value = '';
if (nameInput) nameInput.value = '';
if (urlInput) urlInput.value = '';
if (tokenInput) tokenInput.value = '';
if (colorInput) colorInput.value = 'amber';
if (tagsInput) tagsInput.value = '';
const titleEl = document.getElementById('fleet-editor-title');
if (titleEl) titleEl.textContent = 'Add New Fleet Node';
const badgeEl = document.getElementById('fleet-editor-badge');
if (badgeEl) badgeEl.textContent = 'New Node';
selectFleetColor('amber');
const results = document.getElementById('fleet-test-results');
if (results) {
results.innerHTML = `<span>Click "Test Connection" to query node health, version, and latency.</span>`;
}
renderFleetManagerList();
}
function selectFleetColor(color) {
const colorInput = document.getElementById('fleet-input-color');
if (colorInput) colorInput.value = color;
document.querySelectorAll('.fleet-color-swatch').forEach(sw => {
if (sw.getAttribute('data-color') === color) {
sw.classList.add('active');
} else {
sw.classList.remove('active');
}
});
}
function toggleFleetTokenVisibility() {
const input = document.getElementById('fleet-input-token');
const btn = document.getElementById('btn-toggle-fleet-token') || input?.parentElement?.querySelector('button');
if (!input || !btn) return;
if (input.type === 'password') {
input.type = 'text';
btn.innerHTML = '<i data-lucide="eye-off" id="fleet-token-eye-icon" style="width: 14px; height: 14px; margin: 0;"></i>';
} else {
input.type = 'password';
btn.innerHTML = '<i data-lucide="eye" id="fleet-token-eye-icon" style="width: 14px; height: 14px; margin: 0;"></i>';
}
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: btn });
}
} catch (_) {}
}
async function testFleetNodeConnection() {
let url = (document.getElementById('fleet-input-url')?.value || '').trim();
const token = (document.getElementById('fleet-input-token')?.value || '').trim();
const results = document.getElementById('fleet-test-results');
const btn = document.getElementById('btn-fleet-test');
if (!url) {
showToast('Please enter an endpoint URL to test', 'error');
if (results) results.innerHTML = `<span style="color: #ef4444;">Missing endpoint URL.</span>`;
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
const urlInput = document.getElementById('fleet-input-url');
if (urlInput) urlInput.value = url;
}
const endpoint = url.replace(/\/+$/, '');
const healthUrl = `${endpoint}/api/health`;
if (results) {
results.innerHTML = `<span style="color: var(--accent);"><i data-lucide="loader-2" class="spin" style="width: 12px; height: 12px;"></i> Probing node endpoint ${escapeHtml(healthUrl)}...</span>`;
try {
if (window.lucide && typeof lucide.createIcons === 'function') {
lucide.createIcons({ root: results });
}
} catch (_) {}
}
if (btn) btn.disabled = true;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const t0 = performance.now();
try {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(healthUrl, {
method: 'GET',
headers,
signal: controller.signal,
cache: 'no-store'
});
clearTimeout(timeoutId);
const latency = Math.round(performance.now() - t0);
if (resp.ok) {
const data = await resp.json();
const cleanNode = data.version ? String(data.version).trim().replace(/^v/, '') : '';
const cleanLocal = App.version ? String(App.version).trim().replace(/^v/, '') : '';
const isMismatch = cleanNode && cleanLocal && cleanNode !== cleanLocal;
let mismatchBanner = '';
if (isMismatch) {
mismatchBanner = `
<div style="margin-top: 4px; padding: 4px 8px; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 4px; color: #f59e0b; font-size: 10.5px; display: flex; align-items: center; gap: 6px;">
<i data-lucide="alert-triangle" style="width: 13px; height: 13px; flex-shrink: 0;"></i>
<span>Version mismatch: Node runs <strong>v${escapeHtml(cleanNode)}</strong> while coordinator is <strong>v${escapeHtml(cleanLocal)}</strong>. Features (such as archive formats or APIs) may differ.</span>
</div>
`;
}
if (results) {
results.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 3px;">
<div style="color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 4px;">
<i data-lucide="check-circle-2" style="width: 13px; height: 13px;"></i> Connected successfully (${latency}ms round-trip)
</div>
<div style="font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; flex-wrap: wrap;">
<span>Host: <span style="color: var(--text-main);">${escapeHtml(data.hostname || data.node_name || 'unknown')}</span></span> •
<span>Version: ${getFleetNodeVersionBadge(data.version)}</span> •
<span>OS/Arch: <span style="color: var(--text-main);">${escapeHtml(data.os || '?')}/${escapeHtml(data.arch || '?')}</span></span> •
<span>Auth: <span style="color: var(--text-main);">${data.auth_enabled ? 'JWT/Active' : 'Open/None'}</span></span>
</div>
${mismatchBanner}
</div>
`;
}
showToast(`Node connection verified (${latency}ms)!`, 'success');
} else if (resp.status === 401 || resp.status === 403) {
if (results) {
results.innerHTML = `
<div style="color: #f59e0b; font-weight: 600; display: flex; align-items: center; gap: 4px;">
<i data-lucide="alert-triangle" style="width: 13px; height: 13px;"></i> HTTP ${resp.status} Unauthorized (${latency}ms)
</div>
<div style="font-size: 11px; color: var(--text-dim);">Node reached, but rejected authentication. Please provide a valid Bearer token.</div>
`;
}
showToast(`Node rejected authentication (HTTP ${resp.status})`, 'warning');
} else {
if (results) {
results.innerHTML = `<div style="color: #ef4444;"><i data-lucide="x-circle" style="width: 13px; height: 13px;"></i> HTTP error ${resp.status}: ${escapeHtml(resp.statusText)}</div>`;
}
showToast(`Node returned HTTP ${resp.status}`, 'error');
}
} catch (err) {
clearTimeout(timeoutId);
if (results) {
results.innerHTML = `<div style="color: #ef4444;"><i data-lucide="x-circle" style="width: 13px; height: 13px;"></i> Connection failed: ${escapeHtml(err.message || 'Network error / offline')}</div>`;
}
showToast(`Connection failed: ${err.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
try {
if (window.lucide && typeof lucide.createIcons === 'function' && results) {
lucide.createIcons({ root: results });
}
} catch (_) {}
}
}
function saveFleetNodeProfile() {
const idInput = document.getElementById('fleet-input-id')?.value || '';
let name = (document.getElementById('fleet-input-name')?.value || '').trim();
let url = (document.getElementById('fleet-input-url')?.value || '').trim();
const token = (document.getElementById('fleet-input-token')?.value || '').trim();
const color = document.getElementById('fleet-input-color')?.value || 'amber';
const tagsRaw = (document.getElementById('fleet-input-tags')?.value || '').trim();
if (!url) {
showToast('Endpoint URL is required', 'error');
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
url = url.replace(/\/+$/, '');
if (!name) {
try {
const u = new URL(url);
name = u.hostname || url;
} catch (_) {
name = url;
}
}
const tags = tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
let nodes = loadFleetNodes();
if (idInput) {
// Update existing
const existingIndex = nodes.findIndex(n => n.id === idInput);
if (existingIndex >= 0) {
nodes[existingIndex] = {
...nodes[existingIndex],
name,
endpoint_url: url,
auth_token: token,
color_accent: color,
tags,
updated_at: Date.now()
};
saveFleetNodes(nodes);
showToast(`Updated node profile "${name}"`, 'success');
pingFleetNode(idInput);
}
} else {
// Create new
const newId = 'node_' + Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
const newNode = {
id: newId,
name,
endpoint_url: url,
auth_token: token,
color_accent: color,
tags,
created_at: Date.now(),
status: 'unknown',
latency_ms: null,
version: null,
hostname: null,
os: null,
arch: null
};
nodes.push(newNode);
saveFleetNodes(nodes);
showToast(`Added fleet node "${name}"`, 'success');
pingFleetNode(newId);
}
resetFleetNodeForm();
renderFleetManagerList();
renderFleetSwitcherDropdown();
}
async function deleteFleetNodeProfile(id, event) {
if (event) event.stopPropagation();
const node = getFleetNodeById(id);
if (!node) return;
const confirmed = await showConfirmDialog({
title: 'Delete Fleet Node',
subtitle: `Remove "${node.name}" from fleet`,
message: `Are you sure you want to remove node "${node.name}" (${node.endpoint_url}) from your Fleet Control registry?`,
icon: 'trash-2',
type: 'danger',
confirmText: 'Delete Node'
});
if (!confirmed) return;
let nodes = loadFleetNodes().filter(n => n.id !== id);
saveFleetNodes(nodes);
if (getActiveFleetNodeId() === id) {
localStorage.removeItem(FLEET_ACTIVE_KEY);
}
showToast(`Node "${node.name}" deleted from fleet`, 'info');
resetFleetNodeForm();
renderFleetManagerList();
renderFleetSwitcherDropdown();
}
function exportFleetNodesJson() {
const nodes = loadFleetNodes();
const blob = new Blob([JSON.stringify(nodes, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `commanderdog-fleet-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
showToast('Fleet registry exported to JSON', 'success');
}
function triggerImportFleetNodes() {
const fileInput = document.getElementById('fleet-import-file');
if (fileInput) {
fileInput.value = '';
fileInput.click();
}
}
function handleFleetNodesImport(event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result);
if (!Array.isArray(imported)) {
throw new Error('Invalid format: root must be a JSON array of node objects');
}
let current = loadFleetNodes();
let addedCount = 0;
let updatedCount = 0;
imported.forEach(n => {
if (!n.endpoint_url) return;
const existingIdx = current.findIndex(c => c.id === n.id || c.endpoint_url === n.endpoint_url);
if (existingIdx >= 0) {
current[existingIdx] = { ...current[existingIdx], ...n };
updatedCount++;
} else {
current.push({
id: n.id || ('node_' + Math.random().toString(36).substring(2, 9) + Date.now().toString(36)),
name: n.name || n.endpoint_url,
endpoint_url: n.endpoint_url,
auth_token: n.auth_token || '',
color_accent: n.color_accent || 'amber',
tags: Array.isArray(n.tags) ? n.tags : [],
created_at: n.created_at || Date.now(),
status: 'unknown'
});
addedCount++;
}
});
saveFleetNodes(current);
renderFleetManagerList();
renderFleetSwitcherDropdown();
pingAllFleetNodes();
showToast(`Imported Fleet JSON: ${addedCount} added, ${updatedCount} updated`, 'success');
} catch (err) {
showToast(`Failed to parse fleet JSON: ${err.message}`, 'error');
}
};
reader.readAsText(file);
}
// Global click handler to dismiss fleet dropdown
document.addEventListener('click', (e) => {
if (!e.target.closest('#fleet-switcher-wrapper')) {
closeFleetSwitcherDropdown();
}
});
// ============================================================================
// 🧩 MODULAR CHEWTOYS & EXTENSIONS ENGINE (.grr) & window.Brum SDK
// ============================================================================
window.installedChewToys = [];
window.activeDynamicChewToyId = null;
let dynamicChewToyDragInit = false;
let brumSdkReadyCallbacks = [];
let brumLastContext = null;
/**
* Universal window.Brum Host Bridge SDK
* Enables sandboxed ChewToys to query filesystem, UI, and host window state.
*/
window.Brum = {
version: (typeof App !== 'undefined' && App.version) ? App.version : '0.8.5',
onReady: function(callback) {
if (typeof callback === 'function') {
if (brumLastContext) {
setTimeout(() => callback(brumLastContext), 0);
} else {
brumSdkReadyCallbacks.push(callback);
}
}
},
_dispatchReady: function(context) {
brumLastContext = context;
brumSdkReadyCallbacks.forEach(cb => {
try { cb(context); } catch (err) { console.error('Brum SDK onReady error:', err); }
});
},
plugins: {
list: async function() {
const resp = await fetch('/api/plugins', {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!resp.ok) throw new Error(await resp.text());
return await resp.json();
},
install: function(file) {
return installChewToyPackage(file);
},
toggle: function(id, enabled) {
if (enabled === undefined) {
return toggleDynamicChewToy(id);
}
return toggleChewToy(id, enabled);
},
toggleWindow: function(id, context) {
return toggleDynamicChewToy(id, context);
},
uninstall: function(id) {
return uninstallChewToy(id);
},
open: function(id, context) {
return openDynamicChewToy(id, context);
},
close: function(id) {
return closeDynamicChewToy(id);
}
},
fs: {
readFile: async function(path, opts = {}) {
const targetPath = typeof path === 'object' ? (path.path || path.name) : path;
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = `/api/fs/read?path=${encodeURIComponent(targetPath)}${opts.maxBytes ? `&max_bytes=${opts.maxBytes}` : ''}`;
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
return (data && data.content !== undefined) ? data.content : data;
},
writeFile: async function(path, content) {
const targetPath = typeof path === 'object' ? (path.path || path.name) : path;
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch('/api/fs/write', {
method: 'POST',
headers,
body: JSON.stringify({
path: targetPath,
content: typeof content === 'string' ? content : String(content)
})
});
if (!resp.ok) throw new Error(await resp.text());
return await resp.json();
},
listDir: async function(path) {
const targetPath = path || (App.panes[App.activePaneIndex]?.path || App.panes[0]?.path || '/');
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(`/api/fs/list?path=${encodeURIComponent(targetPath)}`, { headers });
if (!resp.ok) throw new Error(await resp.text());
return await resp.json();
},
getActivePath: function() {
const pane = App.panes[App.activePaneIndex] || App.panes[0];
return pane ? pane.path : '/';
},
getSelectedFiles: function() {
if (brumLastContext && Array.isArray(brumLastContext.selectedFiles) && brumLastContext.selectedFiles.length > 0) {
return brumLastContext.selectedFiles;
}
return getSelectedOrCursorPaths();
},
getTargetFile: function() {
if (brumLastContext && brumLastContext.targetFile) {
return brumLastContext.targetFile;
}
const files = window.Brum.fs.getSelectedFiles();
return (files && files.length > 0) ? files[0] : null;
}
},
ui: {
notify: function(message, opts = {}) {
const type = typeof opts === 'string' ? opts : (opts?.type || 'info');
showToast(message, type);
},
getTheme: function() {
const isDark = !document.body.classList.contains('theme-light') && !document.body.classList.contains('theme-zink');
return {
id: App.theme || 'amber-charcoal',
isDark: isDark,
accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#f59e0b'
};
},
showConfirm: async function(title, message) {
return await showConfirmDialog({
title: title || 'Confirmation',
message: message || '',
confirmText: 'Confirm',
cancelText: 'Cancel'
});
}
},
window: {
dockTo: function(paneNumber) {
dockActiveDynamicChewToy(paneNumber);
},
float: function() {
const dockedIdx = App.panes.findIndex(p => p.dockedTool && (p.dockedTool.startsWith('plugin:') || p.dockedTool.startsWith('chewtoy:')));
if (dockedIdx !== -1) {
undockToolFromPane(dockedIdx);
}
},
close: function() {
closeDynamicChewToy();
const dockedIdx = App.panes.findIndex(p => p.dockedTool && (p.dockedTool.startsWith('plugin:') || p.dockedTool.startsWith('chewtoy:')));
if (dockedIdx !== -1) {
closeDockedTool(dockedIdx);
}
},
setTitle: function(title) {
if (window.activeDynamicChewToyId) {
const titleEl = document.getElementById(`dynamic-chewtoy-title-${window.activeDynamicChewToyId}`) || document.getElementById('dynamic-chewtoy-title');
if (titleEl) titleEl.textContent = title;
}
}
},
auth: {
getUser: function() {
const canInst = !!(App.user?.can_install_plugins || App.user?.role === 'admin' || App.isStandalone);
return {
username: App.user?.username || 'user',
nickname: App.user?.nickname || App.user?.username || 'user',
role: App.user?.role || 'user',
canInstall: canInst
};
},
getToken: function() {
return App.token;
}
}
};
// Listen for PostMessage bridge commands from ChewToy iframes
window.addEventListener('message', async (e) => {
if (!e.data || e.data.type !== 'BRUM_REQ') return;
const { reqId, action, payload } = e.data;
try {
let result = null;
if (action === 'fs.readFile') result = await window.Brum.fs.readFile(payload.path, payload.opts);
else if (action === 'fs.writeFile') result = await window.Brum.fs.writeFile(payload.path, payload.content);
else if (action === 'fs.listDir') result = await window.Brum.fs.listDir(payload.path);
else if (action === 'fs.getActivePath') result = window.Brum.fs.getActivePath();
else if (action === 'fs.getSelectedFiles') result = window.Brum.fs.getSelectedFiles();
else if (action === 'fs.getTargetFile') result = window.Brum.fs.getTargetFile();
else if (action === 'ui.notify') { window.Brum.ui.notify(payload.message, payload.opts); result = true; }
else if (action === 'ui.getTheme') result = window.Brum.ui.getTheme();
else if (action === 'ui.showConfirm') result = await window.Brum.ui.showConfirm(payload.title, payload.message);
else if (action === 'window.dockTo') {
const frameEl = Array.from(document.querySelectorAll('iframe')).find(f => f.contentWindow === e.source);
const winEl = frameEl ? frameEl.closest('.floating-chewtoy-window') : null;
const pId = winEl ? winEl.getAttribute('data-plugin-id') : window.activeDynamicChewToyId;
dockActiveDynamicChewToy(payload.pane, pId);
result = true;
}
else if (action === 'window.float') { window.Brum.window.float(); result = true; }
else if (action === 'window.close') {
const frameEl = Array.from(document.querySelectorAll('iframe')).find(f => f.contentWindow === e.source);
const winEl = frameEl ? frameEl.closest('.floating-chewtoy-window') : null;
const pId = winEl ? winEl.getAttribute('data-plugin-id') : window.activeDynamicChewToyId;
closeDynamicChewToy(pId);
result = true;
}
else if (action === 'window.setTitle') {
const frameEl = Array.from(document.querySelectorAll('iframe')).find(f => f.contentWindow === e.source);
const winEl = frameEl ? frameEl.closest('.floating-chewtoy-window') : null;
if (winEl) {
const titleEl = winEl.querySelector('.chewtoy-brand-text');
if (titleEl) titleEl.textContent = payload.title;
} else {
window.Brum.window.setTitle(payload.title);
}
result = true;
}
else if (action === 'auth.getUser') result = window.Brum.auth.getUser();
e.source?.postMessage({ type: 'BRUM_RES', reqId, result, error: null }, '*');
} catch (err) {
e.source?.postMessage({ type: 'BRUM_RES', reqId, result: null, error: err.message || String(err) }, '*');
}
});
/**
* Load installed ChewToys from backend API and render into Settings
*/
async function loadInstalledChewToys(forceRefresh = false) {
const container = document.getElementById('chewtoys-installed-grid');
const badge = document.getElementById('chewtoys-count-badge');
const dropzone = document.getElementById('chewtoy-dropzone');
const btnInstall = document.getElementById('btn-install-chewtoy');
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const resp = await fetch('/api/plugins', { headers });
if (resp.ok) {
const data = await resp.json();
const plugins = Array.isArray(data) ? data : (data.plugins || []);
const canInstall = (data && data.can_install !== undefined)
? data.can_install
: !!(App.user?.can_install_plugins || App.user?.role === 'admin' || App.isStandalone);
if (dropzone) dropzone.style.display = canInstall ? 'block' : 'none';
if (btnInstall) btnInstall.style.display = canInstall ? 'flex' : 'none';
window.installedChewToys = plugins;
if (badge) badge.textContent = String(window.installedChewToys.length);
renderInstalledChewToys(window.installedChewToys);
if (typeof renderToolsMenu === 'function') renderToolsMenu();
} else {
if (container) {
container.innerHTML = `<div style="grid-column: 1 / -1; padding: 24px; text-align: center; color: var(--danger);">Failed to load ChewToys: ${escapeHtml(await resp.text())}</div>`;
}
}
} catch (err) {
console.error('loadInstalledChewToys error:', err);
if (container) {
container.innerHTML = `<div style="grid-column: 1 / -1; padding: 24px; text-align: center; color: var(--danger);">Error loading ChewToys: ${escapeHtml(err.message)}</div>`;
}
}
}
/**
* Render installed Chewtoys grid cards
*/
function renderInstalledChewToys(plugins) {
const container = document.getElementById('chewtoys-installed-grid');
if (!container) return;
if (!plugins || plugins.length === 0) {
container.innerHTML = `
<div style="grid-column: 1 / -1; padding: 36px 20px; text-align: center; color: var(--text-dim); background: var(--bg-dark); border: 1px dashed var(--border); border-radius: var(--radius);">
<i data-lucide="puzzle" style="width: 32px; height: 32px; margin-bottom: 8px; color: var(--accent); opacity: 0.8;"></i>
<div style="font-weight: 600; font-size: 13px; color: var(--text-main);">No Chewtoys Installed</div>
<div style="font-size: 11.5px; opacity: 0.7; margin-top: 4px;">Drag and drop a <code>.grr</code> package above or click Install to add modular Chewtoys.</div>
</div>
`;
if (window.lucide) lucide.createIcons();
return;
}
container.innerHTML = plugins.map(p => {
const iconUrl = getChewtoyIconUrl(p);
const isEnabled = p.is_enabled !== undefined ? !!p.is_enabled : (p.enabled !== undefined ? !!p.enabled : true);
const permissions = Array.isArray(p.permissions) ? p.permissions : (p.manifest?.permissions?.permissions || []);
const integrations = p.manifest?.integrations || p.integrations || {};
const shortcut = integrations.shortcut || null;
const fileExts = Array.isArray(integrations.file_extensions) ? integrations.file_extensions : [];
const contextLabel = integrations.context_menu_label || null;
return `
<div class="chewtoy-card" id="chewtoy-card-${escapeHtml(p.id)}" style="${!isEnabled ? 'opacity: 0.75;' : ''}">
<div>
<div class="chewtoy-card-header">
<img src="${escapeHtml(iconUrl)}" alt="Icon" class="chewtoy-card-icon" onerror="this.src='assets/amber-frameless-apps.webp'">
<div style="flex: 1; min-width: 0;">
<div class="chewtoy-card-title">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(p.name || p.id)}</span>
<span class="badge" style="font-size: 10px; padding: 1px 5px; background: rgba(245,158,11,0.15); color: var(--accent);">v${escapeHtml(p.version || '1.0.0')}</span>
${p.category ? `<span class="badge" style="font-size: 10px; padding: 1px 5px; opacity: 0.8;">${escapeHtml(p.category)}</span>` : ''}
${shortcut ? `<span class="badge" style="font-size: 10px; padding: 1px 5px; background: rgba(59,130,246,0.15); color: #60a5fa;" title="Global Shortcut: ${escapeHtml(shortcut)}"><i data-lucide="keyboard" style="width: 10px; height: 10px; margin-right: 2px;"></i>${escapeHtml(shortcut)}</span>` : ''}
</div>
<div style="font-size: 10.5px; color: var(--text-dim); margin-top: 2px;">
${escapeHtml(p.author || 'Custom Developer')}
</div>
</div>
</div>
<div class="chewtoy-card-desc">
${escapeHtml(p.description || 'Modular Chewtoy extension package.')}
</div>
${fileExts.length > 0 ? `
<div style="font-size: 10.5px; color: var(--text-dim); margin-top: 6px; display: flex; align-items: center; gap: 5px; flex-wrap: wrap;">
<span class="badge" style="font-size: 9.5px; padding: 1px 5px; background: rgba(34,197,94,0.15); color: #4ade80;" title="Context Menu: ${escapeHtml(contextLabel || 'Open in ' + (p.name || p.id))}"><i data-lucide="file-check" style="width: 10px; height: 10px; margin-right: 2px;"></i>${escapeHtml(fileExts.join(', '))}</span>
</div>
` : ''}
${permissions.length > 0 ? `
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px;">
${permissions.map(perm => `<span class="chewtoy-perm-tag ${perm.includes('pty') ? 'perm-pty' : (perm.includes('fs') ? 'perm-fs' : '')}">${escapeHtml(perm)}</span>`).join('')}
</div>
` : ''}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-top: 1px solid rgba(255,255,255,0.06); padding-top: 8px; margin-top: 4px;">
<div style="display: flex; gap: 6px; align-items: center;">
<button class="btn btn-xs btn-accent" onclick="openDynamicChewToy('${escapeHtml(p.id)}')" ${!isEnabled ? 'disabled style="opacity:0.5;"' : ''} style="display: inline-flex; align-items: center; gap: 4px; cursor: pointer;">
<i data-lucide="play" style="width: 11px; height: 11px;"></i> Launch
</button>
<button class="btn btn-xs btn-icon btn-danger" onclick="uninstallChewToy('${escapeHtml(p.id)}')" title="Uninstall Chewtoy" style="width: 24px; height: 24px;">
<i data-lucide="trash-2" style="width: 11px; height: 11px;"></i>
</button>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 10.5px; color: var(--text-dim);">${isEnabled ? 'Enabled' : 'Disabled'}</span>
<label class="admin-chip-checkbox ${isEnabled ? 'checked' : ''}" style="margin: 0; padding: 2px 8px; cursor: pointer;" title="Toggle Enabled / Disabled">
<input type="checkbox" ${isEnabled ? 'checked' : ''} onchange="toggleChewToy('${escapeHtml(p.id)}', this.checked)" style="display: none;">
<span style="font-size: 10px; font-weight: 700;">${isEnabled ? 'ON' : 'OFF'}</span>
</label>
</div>
</div>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons();
}
/**
* Filter installed ChewToys
*/
function filterInstalledChewToys(query) {
const q = (query || '').toLowerCase().trim();
if (!q) {
renderInstalledChewToys(window.installedChewToys);
return;
}
const filtered = (window.installedChewToys || []).filter(p => {
return (p.name || '').toLowerCase().includes(q) ||
(p.id || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.author || '').toLowerCase().includes(q) ||
(p.category || '').toLowerCase().includes(q);
});
renderInstalledChewToys(filtered);
}
/**
* Drag & Drop handlers for ChewToy .grr packages
*/
function handleChewToyDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
const dropzone = document.getElementById('chewtoy-dropzone');
if (dropzone) dropzone.classList.add('dragover');
}
function handleChewToyDragLeave(e) {
e.preventDefault();
e.stopPropagation();
const dropzone = document.getElementById('chewtoy-dropzone');
if (dropzone) dropzone.classList.remove('dragover');
}
function handleChewToyDrop(e) {
e.preventDefault();
e.stopPropagation();
const dropzone = document.getElementById('chewtoy-dropzone');
if (dropzone) dropzone.classList.remove('dragover');
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
installChewToyPackage(e.dataTransfer.files[0]);
}
}
function handleChewToyFileSelect(e) {
if (e.target && e.target.files && e.target.files.length > 0) {
installChewToyPackage(e.target.files[0]);
e.target.value = '';
}
}
/**
* Upload & install .grr ChewToy archive
*/
async function installChewToyPackage(file) {
if (!file) return;
const name = file.name || '';
if (!name.toLowerCase().endsWith('.grr') && !name.toLowerCase().endsWith('.zip')) {
showToast('Invalid package. Only .grr or .zip ChewToy packages are supported.', 'warning');
return;
}
const dropzone = document.getElementById('chewtoy-dropzone');
if (dropzone) dropzone.style.opacity = '0.5';
showToast(`Installing ChewToy '${name}'...`, 'info');
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = { 'Content-Type': 'application/octet-stream' };
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const resp = await fetch('/api/plugins/install', {
method: 'POST',
headers: headers,
body: file
});
if (resp.ok) {
const plugin = await resp.json();
showToast(`Installed ChewToy: ${plugin.name || plugin.id} (v${plugin.version || '1.0.0'})!`, 'success');
await loadInstalledChewToys(true);
} else {
const err = await resp.text();
showToast(`Failed to install ChewToy: ${err}`, 'error');
}
} catch (err) {
showToast(`Install error: ${err.message || err}`, 'error');
} finally {
if (dropzone) dropzone.style.opacity = '1';
}
}
/**
* Toggle ChewToy enabled state
*/
async function toggleChewToy(id, enabled) {
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}/toggle`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ enabled })
});
if (resp.ok) {
showToast(`${enabled ? 'Enabled' : 'Disabled'} ChewToy '${id}'`, 'info');
await loadInstalledChewToys(true);
} else {
showToast(`Failed to toggle ChewToy: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Toggle error: ${e.message || e}`, 'error');
}
}
/**
* Uninstall ChewToy package
*/
async function uninstallChewToy(id) {
const confirmed = await showConfirmDialog({
title: 'Uninstall ChewToy',
subtitle: `Remove '${id}' from Brum`,
message: `Are you sure you want to uninstall the ChewToy '${id}'? This will permanently delete its package and installed files.`,
type: 'danger',
confirmText: 'Uninstall',
cancelText: 'Cancel'
});
if (!confirmed) return;
const token = App.token || localStorage.getItem('cd_token') || '';
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers
});
if (resp.ok) {
showToast(`ChewToy '${id}' uninstalled successfully`, 'success');
await loadInstalledChewToys(true);
} else {
showToast(`Failed to uninstall: ${await resp.text()}`, 'error');
}
} catch (e) {
showToast(`Uninstall error: ${e.message || e}`, 'error');
}
}
/**
* Dynamic Multi-Instance Chewtoy Host Window Manager (.grr)
*/
function getOrCreateDynamicChewToyWindow(pluginId, plugin) {
let win = document.getElementById(`dynamic-chewtoy-window-${pluginId}`);
if (win) return win;
// Check if static placeholder exists and is unused
const defaultWin = document.getElementById('dynamic-chewtoy-window');
if (defaultWin && (!defaultWin.getAttribute('data-plugin-id') || defaultWin.getAttribute('data-plugin-id') === pluginId)) {
defaultWin.id = `dynamic-chewtoy-window-${pluginId}`;
defaultWin.setAttribute('data-plugin-id', pluginId);
defaultWin.classList.add('dynamic-chewtoy-window');
defaultWin.onclick = function() {
bringFloatingWindowToFront(this);
window.activeDynamicChewToyId = pluginId;
};
const titleEl = defaultWin.querySelector('#dynamic-chewtoy-title');
if (titleEl) titleEl.id = `dynamic-chewtoy-title-${pluginId}`;
const iconEl = defaultWin.querySelector('#dynamic-chewtoy-icon');
if (iconEl) iconEl.id = `dynamic-chewtoy-icon-${pluginId}`;
const verEl = defaultWin.querySelector('#dynamic-chewtoy-version');
if (verEl) verEl.id = `dynamic-chewtoy-version-${pluginId}`;
const frameEl = defaultWin.querySelector('#dynamic-chewtoy-frame');
if (frameEl) frameEl.id = `dynamic-chewtoy-frame-${pluginId}`;
const dockBtn = defaultWin.querySelector('#btn-dynamic-chewtoy-dock');
if (dockBtn) {
dockBtn.id = `btn-dynamic-chewtoy-dock-${pluginId}`;
dockBtn.setAttribute('onclick', `dockActiveDynamicChewToy(undefined, '${pluginId}')`);
}
const maxBtn = defaultWin.querySelector('#btn-dynamic-chewtoy-maximize');
if (maxBtn) {
maxBtn.id = `btn-dynamic-chewtoy-maximize-${pluginId}`;
maxBtn.setAttribute('onclick', `toggleMaximizeDynamicChewToy('${pluginId}')`);
}
const closeBtn = defaultWin.querySelector('.modal-close-btn');
if (closeBtn) {
closeBtn.setAttribute('onclick', `closeDynamicChewToy('${pluginId}')`);
}
return defaultWin;
}
// Create new dedicated floating window for this plugin
win = document.createElement('div');
win.className = 'floating-chewtoy-window dynamic-chewtoy-window';
win.id = `dynamic-chewtoy-window-${pluginId}`;
win.setAttribute('data-plugin-id', pluginId);
win.style.display = 'none';
win.onclick = function() {
bringFloatingWindowToFront(this);
window.activeDynamicChewToyId = pluginId;
};
const iconUrl = getChewtoyIconUrl(plugin);
const title = plugin.name || pluginId;
const version = `v${plugin.version || '1.0.0'}`;
win.innerHTML = `
<div class="dynamic-chewtoy-box" id="dynamic-chewtoy-box-${pluginId}">
<div class="modal-header dynamic-chewtoy-header" id="dynamic-chewtoy-header-${pluginId}" data-plugin-id="${pluginId}" style="height: 42px; min-height: 42px; cursor: grab; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); background: var(--bg-dark);">
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden;">
<img id="dynamic-chewtoy-icon-${pluginId}" src="${iconUrl}" onerror="this.src='assets/amber-frameless-apps.webp'" alt="${escapeHtml(title)}" style="width: 18px; height: 18px; object-fit: contain;">
<span id="dynamic-chewtoy-title-${pluginId}" class="chewtoy-brand-text" style="font-weight: 700; font-size: 13.5px; color: var(--accent); white-space: nowrap; text-overflow: ellipsis; overflow: hidden;">${escapeHtml(title)}</span>
<span id="dynamic-chewtoy-version-${pluginId}" class="badge" style="font-size: 10px; padding: 1px 5px; opacity: 0.8;">${escapeHtml(version)}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button class="btn btn-icon desktop-only" id="btn-dynamic-chewtoy-dock-${pluginId}" onclick="dockActiveDynamicChewToy(undefined, '${pluginId}')" title="Dock into Active Pane" style="width: 28px; height: 28px;"><i data-lucide="panel-left-close"></i></button>
<button class="btn btn-icon" id="btn-dynamic-chewtoy-maximize-${pluginId}" onclick="toggleMaximizeDynamicChewToy('${pluginId}')" title="Maximize / Restore" style="width: 28px; height: 28px;"><i data-lucide="maximize-2"></i></button>
<button class="btn btn-icon modal-close-btn" onclick="closeDynamicChewToy('${pluginId}')" title="Close (Esc)" style="width: 28px; height: 28px;"><i data-lucide="x"></i></button>
</div>
</div>
<div class="dynamic-chewtoy-body" style="flex: 1; position: relative; overflow: hidden; background: var(--bg-panel);">
<iframe id="dynamic-chewtoy-frame-${pluginId}" style="width: 100%; height: 100%; border: none; display: block; outline: none;" sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"></iframe>
</div>
</div>
`;
document.body.appendChild(win);
if (window.lucide) lucide.createIcons();
return win;
}
/**
* Open Floating Dynamic Chewtoy Window
*/
function openDynamicChewToy(pluginId, context = null) {
window.activeDynamicChewToyId = pluginId;
const plugin = (window.installedChewToys || []).find(p => p.id === pluginId) || { id: pluginId, name: pluginId, version: '1.0.0', entry_point: 'index.html' };
// Close blocking modals & menus
if (typeof closeModal === 'function') {
closeModal('settings-modal');
}
if (typeof closeToolsMenu === 'function') {
closeToolsMenu();
}
const spotlightM = document.getElementById('spotlight-modal');
if (spotlightM) {
spotlightM.classList.remove('active');
spotlightM.style.display = 'none';
}
const win = getOrCreateDynamicChewToyWindow(pluginId, plugin);
const titleEl = document.getElementById(`dynamic-chewtoy-title-${pluginId}`);
const iconEl = document.getElementById(`dynamic-chewtoy-icon-${pluginId}`);
const verEl = document.getElementById(`dynamic-chewtoy-version-${pluginId}`);
const frame = document.getElementById(`dynamic-chewtoy-frame-${pluginId}`);
if (titleEl) titleEl.textContent = plugin.name || pluginId;
if (iconEl) {
iconEl.src = getChewtoyIconUrl(plugin);
iconEl.onerror = () => { iconEl.src = 'assets/amber-frameless-apps.webp'; };
}
if (verEl) verEl.textContent = `v${plugin.version || '1.0.0'}`;
const activePanePath = App.panes[App.activePaneIndex]?.path || App.panes[0]?.path || '/';
const viewport = window.innerWidth < 600 ? 'phone' : (window.innerWidth <= 1024 ? 'tablet' : 'pc');
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches;
const targetFile = (context && context.targetFile) ? context.targetFile : (App.contextItem ? App.contextItem.path : null);
const selectedFiles = (context && context.selectedFiles) ? context.selectedFiles : getSelectedOrCursorPaths();
const hostContext = {
activePath: activePanePath,
targetFile: targetFile,
selectedFiles: selectedFiles,
paneIndex: App.activePaneIndex,
theme: App.theme || 'amber-charcoal',
pluginId: pluginId,
isDocked: false,
viewport: viewport,
isTouch: isTouch,
...(context || {})
};
brumLastContext = hostContext;
const entry = plugin.entry_point || 'index.html';
const targetFrameUrl = `/api/plugins/${encodeURIComponent(pluginId)}/assets/${entry}`;
if (frame) {
const isFrameLoaded = frame.src && frame.src.includes(`/api/plugins/${encodeURIComponent(pluginId)}/assets/`);
if (!isFrameLoaded) {
frame.src = `${targetFrameUrl}?_t=${Date.now()}`;
frame.onload = () => {
try {
frame.contentWindow.Brum = window.Brum;
} catch (_) {}
if (window.Brum && typeof window.Brum._dispatchReady === 'function') {
window.Brum._dispatchReady(hostContext);
}
try {
frame.contentWindow.postMessage({ type: 'BRUM_READY', context: hostContext }, '*');
} catch (_) {}
try {
frame.contentWindow.focus();
} catch (_) {}
};
} else {
// Frame already running - update context and focus
try {
frame.contentWindow.Brum = window.Brum;
} catch (_) {}
if (window.Brum && typeof window.Brum._dispatchReady === 'function') {
window.Brum._dispatchReady(hostContext);
}
try {
frame.contentWindow.postMessage({ type: 'BRUM_CONTEXT_UPDATE', context: hostContext }, '*');
frame.contentWindow.postMessage({ type: 'BRUM_READY', context: hostContext }, '*');
} catch (_) {}
try {
frame.contentWindow.focus();
} catch (_) {}
}
}
if (win) {
const ui = plugin.manifest?.ui || plugin.ui || {};
if (window.innerWidth > 1024) {
const isAlreadyVisible = win.style.display !== 'none' && win.classList.contains('active');
if (!isAlreadyVisible) {
const targetW = ui.default_width ? Math.min(window.innerWidth - 40, ui.default_width) : Math.min(880, window.innerWidth - 40);
const targetH = ui.default_height ? Math.min(window.innerHeight - 60, ui.default_height) : Math.min(680, window.innerHeight - 60);
win.style.width = `${targetW}px`;
win.style.height = `${targetH}px`;
// Cascade offset if other dynamic windows are open
const otherFloatingWins = Array.from(document.querySelectorAll('.floating-chewtoy-window.active')).filter(w => w !== win && w.style.display !== 'none');
const staggerOffset = (otherFloatingWins.length * 30) % 150;
const left = Math.max(20, Math.min(window.innerWidth - targetW - 20, Math.floor((window.innerWidth - targetW) / 2) + staggerOffset));
const top = Math.max(45, Math.min(window.innerHeight - targetH - 20, Math.floor((window.innerHeight - targetH) / 2) + staggerOffset));
win.style.left = `${left}px`;
win.style.top = `${top}px`;
}
} else {
// Mobile and Tablet: clear inline dimensions to let CSS fullscreen handle layout
win.style.width = '';
win.style.height = '';
win.style.left = '';
win.style.top = '';
}
win.classList.add('active');
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initDynamicChewToyDrag();
if (window.lucide) lucide.createIcons();
}
/**
* Close Floating Dynamic Chewtoy Window
*/
function closeDynamicChewToy(pluginId = null) {
let win = null;
if (pluginId) {
win = document.getElementById(`dynamic-chewtoy-window-${pluginId}`) || document.getElementById('dynamic-chewtoy-window');
} else if (window.activeDynamicChewToyId) {
win = document.getElementById(`dynamic-chewtoy-window-${window.activeDynamicChewToyId}`) || document.getElementById('dynamic-chewtoy-window');
} else {
const wins = Array.from(document.querySelectorAll('.floating-chewtoy-window.active')).filter(w => w.style.display !== 'none');
if (wins.length) {
wins.sort((a, b) => (parseInt(b.style.zIndex || 0, 10) - parseInt(a.style.zIndex || 0, 10)));
win = wins[0];
}
}
if (win) {
win.classList.remove('active');
win.style.display = 'none';
const frame = win.querySelector('iframe');
if (frame) {
frame.src = 'about:blank';
}
const closedId = win.getAttribute('data-plugin-id');
if (closedId && window.activeDynamicChewToyId === closedId) {
const remainingWins = Array.from(document.querySelectorAll('.floating-chewtoy-window.active')).filter(w => w.style.display !== 'none');
if (remainingWins.length) {
remainingWins.sort((a, b) => (parseInt(b.style.zIndex || 0, 10) - parseInt(a.style.zIndex || 0, 10)));
window.activeDynamicChewToyId = remainingWins[0].getAttribute('data-plugin-id') || null;
} else {
window.activeDynamicChewToyId = null;
}
}
}
}
/**
* Toggle Floating / Docked Dynamic Chewtoy Window
*/
function toggleDynamicChewToy(pluginId, context = null) {
if (!pluginId) return;
// 1. Check if docked in any pane
const dockedPaneIndex = App.panes.findIndex(p => p.dockedTool === 'plugin:' + pluginId || p.dockedTool === 'chewtoy:' + pluginId);
if (dockedPaneIndex !== -1) {
closeDockedTool(dockedPaneIndex);
return;
}
// 2. Check if open and visible in floating window
const win = document.getElementById(`dynamic-chewtoy-window-${pluginId}`) || document.getElementById('dynamic-chewtoy-window');
if (win && win.classList.contains('active') && win.style.display !== 'none' && (!win.getAttribute('data-plugin-id') || win.getAttribute('data-plugin-id') === pluginId)) {
closeDynamicChewToy(pluginId);
return;
}
// 3. Otherwise open according to default_mode
const plugin = (window.installedChewToys || []).find(p => p.id === pluginId);
const defaultMode = plugin?.manifest?.ui?.default_mode || plugin?.ui?.default_mode || 'floating';
if (defaultMode === 'docked') {
dockToolToPane('plugin:' + pluginId, App.activePaneIndex ?? 0);
} else {
openDynamicChewToy(pluginId, context);
}
}
/**
* Maximize / Restore Dynamic Chewtoy Window
*/
function toggleMaximizeDynamicChewToy(pluginId = null) {
let win = null;
if (pluginId) {
win = document.getElementById(`dynamic-chewtoy-window-${pluginId}`) || document.getElementById('dynamic-chewtoy-window');
} else if (window.activeDynamicChewToyId) {
win = document.getElementById(`dynamic-chewtoy-window-${window.activeDynamicChewToyId}`) || document.getElementById('dynamic-chewtoy-window');
}
if (win) win.classList.toggle('maximized');
}
/**
* Dock currently open dynamic Chewtoy into Pane 1 or 2
*/
function dockActiveDynamicChewToy(paneNumber, pluginId = null) {
const pId = pluginId || window.activeDynamicChewToyId;
if (!pId) return;
const paneIdx = (paneNumber !== undefined && paneNumber !== null) ? paneNumber - 1 : (App.activePaneIndex ?? 0);
closeDynamicChewToy(pId);
dockToolToPane('plugin:' + pId, paneIdx);
}
/**
* Initialize Draggable Header for Dynamic Chewtoy Windows
*/
function initDynamicChewToyDrag() {
if (dynamicChewToyDragInit) return;
dynamicChewToyDragInit = true;
let isDragging = false;
let dragTargetWin = null;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
document.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
const header = e.target.closest('.dynamic-chewtoy-header');
if (!header) return;
const win = header.closest('.floating-chewtoy-window');
if (!win) return;
if (e.target.closest('button') || e.target.closest('input') || e.target.closest('select') || win.classList.contains('maximized')) return;
isDragging = true;
dragTargetWin = win;
bringFloatingWindowToFront(win);
const pId = win.getAttribute('data-plugin-id');
if (pId) window.activeDynamicChewToyId = pId;
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
header.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging || !dragTargetWin) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = Math.max(0, Math.min(window.innerWidth - dragTargetWin.offsetWidth, winStartX + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - dragTargetWin.offsetHeight, winStartY + dy));
dragTargetWin.style.position = 'fixed';
dragTargetWin.style.left = `${newLeft}px`;
dragTargetWin.style.top = `${newTop}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
if (dragTargetWin) {
const header = dragTargetWin.querySelector('.dynamic-chewtoy-header');
if (header) header.style.cursor = 'grab';
}
dragTargetWin = null;
}
});
initDynamicChewToyKeyboardForwarding();
}
/**
* Forward keyboard events from parent window to active dynamic Chewtoy iframe
*/
let dynamicChewToyKeyForwardingInit = false;
function initDynamicChewToyKeyboardForwarding() {
if (dynamicChewToyKeyForwardingInit) return;
dynamicChewToyKeyForwardingInit = true;
const forwardKey = (e) => {
// If user is typing in a form input/textarea, do not intercept
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return;
// 1. Floating Dynamic Chewtoy Windows (Topmost visible window)
const activeWins = Array.from(document.querySelectorAll('.floating-chewtoy-window.active'))
.filter(w => w.style.display !== 'none');
if (activeWins.length > 0) {
activeWins.sort((a, b) => (parseInt(b.style.zIndex || 0, 10) - parseInt(a.style.zIndex || 0, 10)));
const topWin = activeWins[0];
const frame = topWin.querySelector('iframe');
if (frame && frame.contentWindow) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Space', 'w', 'a', 's', 'd', 'W', 'A', 'S', 'D', 'z', 'x', 'c', 'Z', 'X', 'C', 'p', 'P', 'Escape'].includes(e.key)) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) {
e.preventDefault();
}
try {
frame.contentWindow.postMessage({
type: 'BRUM_KEY',
key: e.key,
code: e.code,
keyType: e.type
}, '*');
} catch (_) {}
return;
}
}
}
// 2. Docked Dynamic Chewtoy in Active Pane
if (Array.isArray(App.panes)) {
const activePane = App.panes[App.activePaneIndex];
if (activePane && activePane.dockedTool && (activePane.dockedTool.startsWith('plugin:') || activePane.dockedTool.startsWith('chewtoy:'))) {
const dFrame = document.getElementById(`docked-chewtoy-frame-${App.activePaneIndex}`);
if (dFrame && dFrame.contentWindow) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Space', 'w', 'a', 's', 'd', 'W', 'A', 'S', 'D', 'z', 'x', 'c', 'Z', 'X', 'C', 'p', 'P', 'Escape'].includes(e.key)) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) {
e.preventDefault();
}
try {
dFrame.contentWindow.postMessage({
type: 'BRUM_KEY',
key: e.key,
code: e.code,
keyType: e.type
}, '*');
} catch (_) {}
}
}
}
}
};
window.addEventListener('keydown', forwardKey);
window.addEventListener('keyup', forwardKey);
}
// ==========================================================================
// 🏷️ CHEWTOY: BATCH RENAMER ENGINE (Ctrl+M)
// ==========================================================================
let renamerItems = [];
let renamerUndoStack = [];
let renamerActivePaneIndex = 0;
let renamerDragInit = false;
let isRenamerDryRunSimulated = false;
function initRenamerDragResize() {
if (renamerDragInit) return;
renamerDragInit = true;
const win = document.getElementById('floating-renamer-window');
const header = document.getElementById('renamer-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_renamer_x');
const savedTop = localStorage.getItem('cd_renamer_y');
const savedWidth = localStorage.getItem('cd_renamer_w');
const savedHeight = localStorage.getItem('cd_renamer_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(440, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(380, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
let isResizing = false;
let resizeMode = null; // 'corner', 'right', 'bottom', 'left', 'top'
let startW = 0, startH = 0;
let resizeStartX = 0, resizeStartY = 0;
let resizeStartLeft = 0, resizeStartTop = 0;
function onResizeStart(e, mode) {
if (window.innerWidth <= 1024 || win.classList.contains('maximized')) return;
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeMode = mode;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
startW = win.offsetWidth;
startH = win.offsetHeight;
const rect = win.getBoundingClientRect();
resizeStartLeft = rect.left;
resizeStartTop = rect.top;
bringFloatingWindowToFront(win);
document.body.style.userSelect = 'none';
if (mode === 'corner') document.body.style.cursor = 'nwse-resize';
else if (mode === 'right' || mode === 'left') document.body.style.cursor = 'ew-resize';
else if (mode === 'bottom' || mode === 'top') document.body.style.cursor = 'ns-resize';
}
const cornerH = document.getElementById('renamer-resize-corner');
const rightH = document.getElementById('renamer-resize-right');
const bottomH = document.getElementById('renamer-resize-bottom');
const leftH = document.getElementById('renamer-resize-left');
const topH = document.getElementById('renamer-resize-top');
if (cornerH) cornerH.addEventListener('mousedown', (e) => onResizeStart(e, 'corner'));
if (rightH) rightH.addEventListener('mousedown', (e) => onResizeStart(e, 'right'));
if (bottomH) bottomH.addEventListener('mousedown', (e) => onResizeStart(e, 'bottom'));
if (leftH) leftH.addEventListener('mousedown', (e) => onResizeStart(e, 'left'));
if (topH) topH.addEventListener('mousedown', (e) => onResizeStart(e, 'top'));
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
return;
}
if (isResizing) {
const dx = e.clientX - resizeStartX;
const dy = e.clientY - resizeStartY;
const minW = 440;
const minH = 340;
const maxW = window.innerWidth - 20;
const maxH = window.innerHeight - 40;
if (resizeMode === 'corner' || resizeMode === 'right') {
const newW = Math.max(minW, Math.min(maxW, startW + dx));
win.style.width = `${newW}px`;
}
if (resizeMode === 'corner' || resizeMode === 'bottom') {
const newH = Math.max(minH, Math.min(maxH, startH + dy));
win.style.height = `${newH}px`;
}
if (resizeMode === 'left') {
const newW = Math.max(minW, Math.min(maxW, startW - dx));
const newLeft = resizeStartLeft + (startW - newW);
win.style.width = `${newW}px`;
win.style.left = `${newLeft}px`;
}
if (resizeMode === 'top') {
const newH = Math.max(minH, Math.min(maxH, startH - dy));
const newTop = resizeStartTop + (startH - newH);
win.style.height = `${newH}px`;
win.style.top = `${newTop}px`;
}
}
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_renamer_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_renamer_y', parseInt(win.style.top, 10));
}
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_renamer_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_renamer_h', parseInt(win.style.height, 10));
if (win.style.left) localStorage.setItem('cd_renamer_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_renamer_y', parseInt(win.style.top, 10));
}
});
}
function openBatchRenamer(files = null, paneIndex = null) {
renamerActivePaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[renamerActivePaneIndex];
const win = document.getElementById('floating-renamer-window');
const body = document.getElementById('renamer-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('renamer-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initRenamerDragResize();
// Populate files
if (Array.isArray(files) && files.length > 0) {
renamerItems = files.map(f => {
const p = typeof f === 'string' ? f : f.path;
const name = p.split('/').filter(Boolean).pop() || p;
return {
path: p,
name: name,
is_dir: f.is_dir || false,
newName: name,
isChecked: true,
hasConflict: false
};
});
} else if (pane && pane.selected && pane.selected.size > 0) {
renamerItems = Array.from(pane.selected).map(p => {
const entry = (pane.entries || []).find(e => e.path === p);
const name = entry ? entry.name : (p.split('/').filter(Boolean).pop() || p);
return {
path: p,
name: name,
is_dir: entry ? entry.is_dir : false,
newName: name,
isChecked: true,
hasConflict: false
};
});
} else if (pane && Array.isArray(pane.entries) && pane.entries.length > 0) {
renamerItems = pane.entries.filter(e => e.name !== '..').map(e => ({
path: e.path,
name: e.name,
is_dir: e.is_dir,
newName: e.name,
isChecked: true,
hasConflict: false
}));
} else {
renamerItems = [];
}
const sourceInfo = document.getElementById('renamer-source-info');
if (sourceInfo) {
const curPath = sanitizeCredentials(pane?.path || '/');
sourceInfo.textContent = `${renamerItems.length} items from ${curPath}`;
sourceInfo.title = curPath;
}
updateRenamerPreview();
}
function closeFloatingRenamer() {
const win = document.getElementById('floating-renamer-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingRenamer() {
const win = document.getElementById('floating-renamer-window');
const pill = document.getElementById('renamer-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingRenamer() {
openBatchRenamer();
}
function toggleMaximizeRenamer() {
const win = document.getElementById('floating-renamer-window');
if (win) win.classList.toggle('maximized');
}
function dockRenamerToActivePane() {
closeFloatingRenamer();
App.panes[App.activePaneIndex].dockedTool = 'renamer';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'renamer');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function reloadRenamerFiles() {
const pane = App.panes[renamerActivePaneIndex];
if (pane && pane.selected && pane.selected.size > 0) {
renamerItems = Array.from(pane.selected).map(p => {
const entry = (pane.entries || []).find(e => e.path === p);
const name = entry ? entry.name : (p.split('/').filter(Boolean).pop() || p);
return {
path: p,
name: name,
is_dir: entry ? entry.is_dir : false,
newName: name,
isChecked: true,
hasConflict: false
};
});
} else {
loadRenamerDirectory();
return;
}
const sourceInfo = document.getElementById('renamer-source-info');
if (sourceInfo) {
sourceInfo.textContent = `${renamerItems.length} selected items from ${sanitizeCredentials(pane?.path || '/')}`;
}
updateRenamerPreview();
}
function loadRenamerDirectory() {
const pane = App.panes[renamerActivePaneIndex];
if (pane && Array.isArray(pane.entries)) {
renamerItems = pane.entries.filter(e => e.name !== '..').map(e => ({
path: e.path,
name: e.name,
is_dir: e.is_dir,
newName: e.name,
isChecked: true,
hasConflict: false
}));
}
const sourceInfo = document.getElementById('renamer-source-info');
if (sourceInfo) {
sourceInfo.textContent = `${renamerItems.length} directory files from ${sanitizeCredentials(pane?.path || '/')}`;
}
updateRenamerPreview();
}
function insertRenamerToken(token) {
const replaceInput = document.getElementById('renamer-replace-input');
if (!replaceInput) return;
const start = replaceInput.selectionStart || replaceInput.value.length;
const end = replaceInput.selectionEnd || replaceInput.value.length;
const val = replaceInput.value;
replaceInput.value = val.substring(0, start) + token + val.substring(end);
replaceInput.selectionStart = replaceInput.selectionEnd = start + token.length;
replaceInput.focus();
updateRenamerPreview();
}
function resetRenamerRules() {
isRenamerDryRunSimulated = false;
const findInput = document.getElementById('renamer-find-input');
const replaceInput = document.getElementById('renamer-replace-input');
const regexCheck = document.getElementById('renamer-regex-check');
const caseCheck = document.getElementById('renamer-case-check');
const casingSelect = document.getElementById('renamer-casing-select');
const extSelect = document.getElementById('renamer-ext-select');
const startInput = document.getElementById('renamer-counter-start');
const stepInput = document.getElementById('renamer-counter-step');
if (findInput) findInput.value = '';
if (replaceInput) replaceInput.value = '';
if (regexCheck) regexCheck.checked = false;
if (caseCheck) caseCheck.checked = false;
if (casingSelect) casingSelect.value = 'none';
if (extSelect) extSelect.value = 'none';
if (startInput) startInput.value = '1';
if (stepInput) stepInput.value = '1';
updateRenamerPreview();
}
function toggleAllRenamerItems(checked) {
renamerItems.forEach(i => i.isChecked = checked);
updateRenamerPreview();
}
function toggleRenamerItem(idx, checked) {
if (renamerItems[idx]) {
renamerItems[idx].isChecked = checked;
updateRenamerPreview();
}
}
function applyCasingTransform(str, mode) {
if (!str || mode === 'none') return str;
switch (mode) {
case 'lower': return str.toLowerCase();
case 'upper': return str.toUpperCase();
case 'title':
return str.replace(/\b[a-z]/g, l => l.toUpperCase());
case 'camel':
return str.replace(/[-_ ]+(\w)/g, (_, c) => c.toUpperCase()).replace(/^[A-Z]/, c => c.toLowerCase());
case 'snake':
return str.replace(/([a-z])([A-Z])/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase();
case 'kebab':
return str.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]+/g, '-').toLowerCase();
case 'sentence':
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
default: return str;
}
}
function executeRenamerDryRun() {
updateRenamerPreview(true);
}
function updateRenamerPreview(isDryRun = false) {
if (isDryRun === true) {
isRenamerDryRunSimulated = true;
} else {
isRenamerDryRunSimulated = false;
}
const findVal = (document.getElementById('renamer-find-input')?.value || '');
const replaceVal = (document.getElementById('renamer-replace-input')?.value || '');
const isRegex = document.getElementById('renamer-regex-check')?.checked || false;
const isCase = document.getElementById('renamer-case-check')?.checked || false;
const casingMode = document.getElementById('renamer-casing-select')?.value || 'none';
const extMode = document.getElementById('renamer-ext-select')?.value || 'none';
const counterStart = parseInt(document.getElementById('renamer-counter-start')?.value || '1', 10) || 1;
const counterStep = parseInt(document.getElementById('renamer-counter-step')?.value || '1', 10) || 1;
let compiledRegex = null;
if (isRegex && findVal) {
try {
compiledRegex = new RegExp(findVal, isCase ? 'g' : 'gi');
} catch (_) {}
}
const now = new Date();
const yyyy = String(now.getFullYear());
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
let activeCounter = counterStart;
const targetNameFrequency = new Map();
// First pass: generate names
renamerItems.forEach((item) => {
if (!item.isChecked) {
item.newName = item.name;
item.hasConflict = false;
return;
}
const origName = item.name;
const lastDot = origName.lastIndexOf('.');
let baseName = (lastDot > 0 && !item.is_dir) ? origName.substring(0, lastDot) : origName;
let ext = (lastDot > 0 && !item.is_dir) ? origName.substring(lastDot + 1) : '';
let transformedBase = baseName;
// 1. Find & Replace
if (findVal) {
if (isRegex && compiledRegex) {
transformedBase = transformedBase.replace(compiledRegex, replaceVal);
} else {
const flag = isCase ? 'g' : 'gi';
const escapedFind = escapeRegex(findVal);
transformedBase = transformedBase.replace(new RegExp(escapedFind, flag), replaceVal);
}
} else if (replaceVal) {
transformedBase = replaceVal;
}
// 2. Token Substitutions
const countNum = activeCounter;
transformedBase = transformedBase
.replace(/\[N\]/g, String(countNum))
.replace(/\[01\]/g, String(countNum).padStart(2, '0'))
.replace(/\[001\]/g, String(countNum).padStart(3, '0'))
.replace(/\[0001\]/g, String(countNum).padStart(4, '0'))
.replace(/\[NAME\]/g, baseName)
.replace(/\[EXT\]/g, ext)
.replace(/\[YYYY\]/g, yyyy)
.replace(/\[MM\]/g, mm)
.replace(/\[DD\]/g, dd)
.replace(/\[hh\]/g, hh)
.replace(/\[mm\]/g, min)
.replace(/\[ss\]/g, ss);
// 3. Case Transformation
transformedBase = applyCasingTransform(transformedBase, casingMode);
// 4. Extension Transformation
let newExt = ext;
if (ext) {
if (extMode === 'lower') newExt = ext.toLowerCase();
else if (extMode === 'upper') newExt = ext.toUpperCase();
else if (extMode === 'strip') newExt = '';
}
const finalName = (newExt && !item.is_dir) ? `${transformedBase}.${newExt}` : transformedBase;
item.newName = finalName || origName;
activeCounter += counterStep;
const count = targetNameFrequency.get(item.newName) || 0;
targetNameFrequency.set(item.newName, count + 1);
});
// Second pass: check collisions
let modifiedCount = 0;
let conflictCount = 0;
renamerItems.forEach((item) => {
if (!item.isChecked) {
item.hasConflict = false;
return;
}
const freq = targetNameFrequency.get(item.newName) || 0;
if (freq > 1 || !item.newName) {
item.hasConflict = true;
conflictCount++;
} else {
item.hasConflict = false;
}
if (item.newName !== item.name) {
modifiedCount++;
}
});
// Render Table
const tbody = document.getElementById('renamer-table-body');
if (tbody) {
if (renamerItems.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 24px; color: var(--text-muted);">No items to rename. Select files in the active pane and click "Reload Selected".</td></tr>`;
} else {
tbody.innerHTML = renamerItems.map((it, idx) => {
const isModified = it.newName !== it.name;
const rowClass = it.hasConflict ? 'renamer-row-conflict' : (isModified ? 'renamer-row-modified' : '');
let statusBadge = '<span class="badge" style="font-size: 9px; opacity: 0.6;">Unchanged</span>';
if (!it.isChecked) {
statusBadge = '<span class="badge" style="font-size: 9px; opacity: 0.4;">Skipped</span>';
} else if (it.hasConflict) {
statusBadge = '<span class="badge" style="background: rgba(239, 68, 68, 0.2); color: #ef4444; font-size: 9px; font-weight: 700;">COLLISION</span>';
} else if (isModified) {
if (isRenamerDryRunSimulated) {
statusBadge = '<span class="badge" style="background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); font-size: 9px; font-weight: 700;">SIMULATED: OK</span>';
} else {
statusBadge = '<span class="badge badge-accent" style="font-size: 9px;">Ready</span>';
}
}
return `
<tr class="${rowClass}">
<td style="text-align: center;"><input type="checkbox" ${it.isChecked ? 'checked' : ''} onchange="toggleRenamerItem(${idx}, this.checked)"></td>
<td title="${escapeHtml(it.name)}" style="max-width: 200px;"><i data-lucide="${it.is_dir ? 'folder' : 'file'}" style="width: 12px; height: 12px; vertical-align: middle; margin-right: 4px;"></i> ${escapeHtml(it.name)}</td>
<td style="text-align: center; color: var(--text-dim);">➔</td>
<td class="renamer-col-new" title="${escapeHtml(it.newName)}" style="max-width: 220px;">${escapeHtml(it.newName)}</td>
<td style="text-align: center;">${statusBadge}</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: tbody });
}
}
// Update Summary
const summaryEl = document.getElementById('renamer-status-summary');
if (summaryEl) {
if (conflictCount > 0) {
summaryEl.innerHTML = `<span style="color: #ef4444;">⚠️ ${conflictCount} naming collision${conflictCount === 1 ? '' : 's'} detected!</span>`;
} else if (isRenamerDryRunSimulated && modifiedCount > 0) {
summaryEl.innerHTML = `<span style="color: #22c55e; font-weight: 700;">✓ Dry run passed: ${modifiedCount} file${modifiedCount === 1 ? '' : 's'} safe to rename</span>`;
} else {
summaryEl.textContent = `${renamerItems.length} items • ${modifiedCount} to rename`;
}
}
const execBtn = document.getElementById('renamer-execute-btn');
if (execBtn) {
execBtn.disabled = modifiedCount === 0 || conflictCount > 0;
}
if (isDryRun) {
if (conflictCount > 0) {
showToast(`⚠️ Dry Run: ${conflictCount} naming collision${conflictCount === 1 ? '' : 's'} detected!`, 'warning');
} else if (modifiedCount === 0) {
showToast('Dry Run: No file renames to perform with current rules', 'info');
} else {
showToast(`✅ Dry Run Complete: ${modifiedCount} of ${renamerItems.length} items simulated successfully (0 collisions)`, 'success');
}
}
}
async function executeBatchRename() {
const toRename = renamerItems.filter(i => i.isChecked && i.name !== i.newName && !i.hasConflict);
if (toRename.length === 0) {
showToast('No valid renames to execute', 'info');
return;
}
const renamesPayload = toRename.map(i => {
const lastSlash = i.path.lastIndexOf('/');
const dir = lastSlash >= 0 ? i.path.substring(0, lastSlash + 1) : '';
const newPath = dir + i.newName;
return {
from: i.path,
to: newPath
};
});
try {
const res = await fetch('/api/fs/batch-rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ renames: renamesPayload })
});
if (res.ok) {
const data = await res.json();
showToast(`Batch Renamed ${data.renamed || renamesPayload.length} files successfully!`, 'success');
// Push inverse operations to undo stack
renamerUndoStack.push(renamesPayload.map(r => ({ from: r.to, to: r.from })));
const undoBtn = document.getElementById('renamer-undo-btn');
const undoCount = document.getElementById('renamer-undo-count');
if (undoBtn) undoBtn.disabled = false;
if (undoCount) undoCount.textContent = renamerUndoStack.length;
refreshAllPanes();
reloadRenamerFiles();
} else {
showToast('Batch rename failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Batch rename error: ' + sanitizeCredentials(String(e)), 'error');
}
}
async function undoBatchRename() {
if (renamerUndoStack.length === 0) return;
const lastBatch = renamerUndoStack.pop();
const undoBtn = document.getElementById('renamer-undo-btn');
const undoCount = document.getElementById('renamer-undo-count');
if (undoBtn) undoBtn.disabled = renamerUndoStack.length === 0;
if (undoCount) undoCount.textContent = renamerUndoStack.length;
try {
const res = await fetch('/api/fs/batch-rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ renames: lastBatch })
});
if (res.ok) {
showToast(`Reverted ${lastBatch.length} file renames`, 'info');
refreshAllPanes();
reloadRenamerFiles();
} else {
showToast('Undo rename failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Undo error: ' + sanitizeCredentials(String(e)), 'error');
}
}
function mountDockedRenamer(paneIndex) {
const host = document.getElementById(`docked-renamer-host-${paneIndex}`);
const floatingBody = document.getElementById('renamer-body');
if (host && floatingBody) {
host.appendChild(floatingBody);
reloadRenamerFiles();
}
}
// ==========================================================================
// 🔬 CHEWTOY: HEX EDITOR ENGINE
// ==========================================================================
let hexEditorState = {
filePath: null,
fileName: 'Untitled',
buffer: null,
originalBuffer: null,
modifiedIndices: new Set(),
activeOffset: 0,
bytesPerRow: 16,
endian: 'le',
isEditMode: true,
inspectorVisible: true,
isDocked: false
};
let hexEditorDragInit = false;
// Precomputed CRC32 Table
const crc32Table = (function() {
let c;
const table = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
}
table[n] = c;
}
return table;
})();
function calculateBufferCRC32(bytes) {
let crc = 0 ^ (-1);
for (let i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ crc32Table[(crc ^ bytes[i]) & 0xFF];
}
return ((crc ^ (-1)) >>> 0).toString(16).padStart(8, '0');
}
function initHexEditorDragResize() {
if (hexEditorDragInit) return;
hexEditorDragInit = true;
const win = document.getElementById('floating-hexeditor-window');
const header = document.getElementById('hexeditor-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_hex_x');
const savedTop = localStorage.getItem('cd_hex_y');
const savedWidth = localStorage.getItem('cd_hex_w');
const savedHeight = localStorage.getItem('cd_hex_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(480, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(400, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_hex_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_hex_y', parseInt(win.style.top, 10));
}
});
}
async function openHexEditor(filePath = null) {
const win = document.getElementById('floating-hexeditor-window');
const body = document.getElementById('hexeditor-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('hexeditor-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initHexEditorDragResize();
const targetPath = filePath || (App.panes[App.activePaneIndex]?.entries[App.panes[App.activePaneIndex]?.cursorIndex]?.path);
if (targetPath) {
hexEditorState.filePath = targetPath;
hexEditorState.fileName = targetPath.split('/').filter(Boolean).pop() || targetPath;
const badge = document.getElementById('hexeditor-file-badge');
if (badge) badge.textContent = sanitizeCredentials(hexEditorState.fileName);
if (targetPath.startsWith('client://')) {
try {
const fileHandle = await resolveClientFileHandle(targetPath, false);
if (!fileHandle) {
showToast('Client local file not found', 'error');
return;
}
const file = await fileHandle.getFile();
const arrayBuf = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuf);
hexEditorState.buffer = bytes;
hexEditorState.originalBuffer = new Uint8Array(bytes);
hexEditorState.modifiedIndices.clear();
hexEditorState.activeOffset = 0;
renderHexEditorView();
computeHexHashes();
} catch (e) {
showToast('Hex read error: ' + sanitizeCredentials(String(e)), 'error');
}
} else {
try {
const res = await fetch(`/api/fs/read?path=${encodeURIComponent(targetPath)}&max_bytes=2000000`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const data = await res.json();
let bytes;
if (data.is_binary) {
const binaryString = atob(data.content);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
} else {
bytes = new TextEncoder().encode(data.content);
}
hexEditorState.buffer = bytes;
hexEditorState.originalBuffer = new Uint8Array(bytes);
hexEditorState.modifiedIndices.clear();
hexEditorState.activeOffset = 0;
renderHexEditorView();
computeHexHashes();
} else {
showToast('Failed to load binary file: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Hex read error: ' + sanitizeCredentials(String(e)), 'error');
}
}
} else {
// Default dummy buffer if opened without file
if (!hexEditorState.buffer) {
hexEditorState.buffer = new Uint8Array(128);
hexEditorState.originalBuffer = new Uint8Array(128);
hexEditorState.modifiedIndices.clear();
renderHexEditorView();
}
}
}
function closeFloatingHexEditor() {
const win = document.getElementById('floating-hexeditor-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingHexEditor() {
const win = document.getElementById('floating-hexeditor-window');
const pill = document.getElementById('hexeditor-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingHexEditor() {
openHexEditor(hexEditorState.filePath);
}
function toggleMaximizeHexEditor() {
const win = document.getElementById('floating-hexeditor-window');
if (win) win.classList.toggle('maximized');
}
function dockHexEditorToActivePane() {
closeFloatingHexEditor();
App.panes[App.activePaneIndex].dockedTool = 'hexeditor';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'hexeditor');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function renderHexEditorView(targetMount = null) {
const container = targetMount || document.getElementById('hexeditor-grid-container');
if (!container || !hexEditorState.buffer) return;
const bytes = hexEditorState.buffer;
const bytesPerRow = parseInt(document.getElementById('hexeditor-bytes-per-row')?.value || '16', 10) || 16;
hexEditorState.bytesPerRow = bytesPerRow;
const rows = [];
const totalRows = Math.ceil(bytes.length / bytesPerRow);
for (let r = 0; r < totalRows; r++) {
const rowOffset = r * bytesPerRow;
const hexParts = [];
const asciiParts = [];
for (let c = 0; c < bytesPerRow; c++) {
const byteOffset = rowOffset + c;
if (byteOffset < bytes.length) {
const b = bytes[byteOffset];
const hexStr = b.toString(16).toUpperCase().padStart(2, '0');
const isSelected = byteOffset === hexEditorState.activeOffset;
const isModified = hexEditorState.modifiedIndices.has(byteOffset);
let hexCls = 'hex-byte';
if (isSelected) hexCls += ' selected';
if (isModified) hexCls += ' modified';
hexParts.push(`<span class="${hexCls}" data-offset="${byteOffset}" onclick="selectHexByte(${byteOffset})">${hexStr}</span>`);
const char = (b >= 32 && b <= 126) ? String.fromCharCode(b) : '·';
let ascCls = 'hex-ascii-char';
if (isSelected) ascCls += ' selected';
if (isModified) ascCls += ' modified';
asciiParts.push(`<span class="${ascCls}" data-offset="${byteOffset}" onclick="selectHexByte(${byteOffset})">${escapeHtml(char)}</span>`);
} else {
hexParts.push(`<span class="hex-byte" style="opacity: 0.2;">--</span>`);
asciiParts.push(`<span class="hex-ascii-char" style="opacity: 0.2;"> </span>`);
}
}
const offsetHex = rowOffset.toString(16).toUpperCase().padStart(8, '0');
rows.push(`
<div class="hex-row">
<span class="hex-offset">${offsetHex}:</span>
<div class="hex-bytes">${hexParts.join('')}</div>
<div class="hex-ascii">${asciiParts.join('')}</div>
</div>
`);
}
container.innerHTML = rows.join('');
updateHexInspector();
const dirtyBadge = document.getElementById('hexeditor-dirty-badge');
const modStatus = document.getElementById('hex-status-modified');
const modCount = hexEditorState.modifiedIndices.size;
if (dirtyBadge) dirtyBadge.style.display = modCount > 0 ? 'inline-block' : 'none';
if (modStatus) modStatus.textContent = `${modCount} byte${modCount === 1 ? '' : 's'} patched`;
}
function selectHexByte(offset) {
if (!hexEditorState.buffer || offset < 0 || offset >= hexEditorState.buffer.length) return;
hexEditorState.activeOffset = offset;
// Quick DOM class update for snappy performance
document.querySelectorAll('.hex-byte.selected, .hex-ascii-char.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll(`[data-offset="${offset}"]`).forEach(el => el.classList.add('selected'));
updateHexInspector();
}
function updateHexInspector() {
if (!hexEditorState.buffer) return;
const offset = hexEditorState.activeOffset;
const bytes = hexEditorState.buffer;
const endian = document.getElementById('hexeditor-endian-select')?.value || 'le';
const isLe = endian === 'le';
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const offEl = document.getElementById('hex-insp-offset');
const statusOff = document.getElementById('hex-status-offset');
if (offEl) offEl.textContent = `0x${offset.toString(16).toUpperCase()} (${offset})`;
if (statusOff) statusOff.textContent = `Offset: 0x${offset.toString(16).toUpperCase().padStart(8, '0')} (${offset})`;
if (offset < bytes.length) {
const u8 = bytes[offset];
document.getElementById('hex-insp-uint8').textContent = u8;
document.getElementById('hex-insp-int8').textContent = view.getInt8(offset);
document.getElementById('hex-insp-binary').textContent = u8.toString(2).padStart(8, '0');
if (offset + 1 < bytes.length) {
document.getElementById('hex-insp-uint16').textContent = view.getUint16(offset, isLe);
document.getElementById('hex-insp-int16').textContent = view.getInt16(offset, isLe);
} else {
document.getElementById('hex-insp-uint16').textContent = '-';
document.getElementById('hex-insp-int16').textContent = '-';
}
if (offset + 3 < bytes.length) {
document.getElementById('hex-insp-uint32').textContent = view.getUint32(offset, isLe);
document.getElementById('hex-insp-int32').textContent = view.getInt32(offset, isLe);
document.getElementById('hex-insp-float32').textContent = view.getFloat32(offset, isLe).toExponential(3);
} else {
document.getElementById('hex-insp-uint32').textContent = '-';
document.getElementById('hex-insp-int32').textContent = '-';
document.getElementById('hex-insp-float32').textContent = '-';
}
if (offset + 7 < bytes.length) {
document.getElementById('hex-insp-float64').textContent = view.getFloat64(offset, isLe).toExponential(3);
} else {
document.getElementById('hex-insp-float64').textContent = '-';
}
}
const sizeEl = document.getElementById('hex-file-size');
if (sizeEl) sizeEl.textContent = formatBytes(bytes.length);
}
async function computeHexHashes() {
if (!hexEditorState.buffer) return;
const bytes = hexEditorState.buffer;
// CRC32
const crc = calculateBufferCRC32(bytes);
const crcEl = document.getElementById('hex-hash-crc32');
if (crcEl) crcEl.textContent = `0x${crc.toUpperCase()}`;
// SHA-256 via Universal Engine
const hexSha = await computeSha256Hex(bytes);
const shaEl = document.getElementById('hex-hash-sha256');
if (shaEl && hexSha) shaEl.textContent = hexSha;
}
function handleHexGridKey(e) {
if (!hexEditorState.buffer) return;
const bpr = hexEditorState.bytesPerRow || 16;
const max = hexEditorState.buffer.length - 1;
if (e.key === 'ArrowRight') {
e.preventDefault();
selectHexByte(Math.min(max, hexEditorState.activeOffset + 1));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
selectHexByte(Math.max(0, hexEditorState.activeOffset - 1));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
selectHexByte(Math.min(max, hexEditorState.activeOffset + bpr));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectHexByte(Math.max(0, hexEditorState.activeOffset - bpr));
} else if (/^[0-9a-fA-F]$/.test(e.key)) {
e.preventDefault();
const curByte = hexEditorState.buffer[hexEditorState.activeOffset];
const newNibble = parseInt(e.key, 16);
// Edit lower nibble then advance
const newByte = ((curByte & 0x0F) << 4) | newNibble;
hexEditorState.buffer[hexEditorState.activeOffset] = newByte;
hexEditorState.modifiedIndices.add(hexEditorState.activeOffset);
renderHexEditorView();
selectHexByte(Math.min(max, hexEditorState.activeOffset + 1));
}
}
function jumpHexOffset() {
const input = document.getElementById('hexeditor-goto-input');
if (!input || !hexEditorState.buffer) return;
let val = input.value.trim();
let target = 0;
if (val.startsWith('0x') || val.startsWith('0X')) {
target = parseInt(val, 16);
} else {
target = parseInt(val, 10);
}
if (!isNaN(target) && target >= 0) {
const clamped = Math.min(hexEditorState.buffer.length - 1, target);
selectHexByte(clamped);
const targetEl = document.querySelector(`.hex-byte[data-offset="${clamped}"]`);
if (targetEl) targetEl.scrollIntoView({ block: 'center' });
}
}
function searchHexBytes() {
const input = document.getElementById('hexeditor-search-input');
if (!input || !hexEditorState.buffer) return;
const q = input.value.trim();
if (!q) return;
const bytes = hexEditorState.buffer;
let searchBytes = [];
if (q.includes(' ') || /^[0-9a-fA-F]{2,}$/.test(q)) {
// Hex search
const clean = q.replace(/\s+/g, '');
for (let i = 0; i < clean.length; i += 2) {
searchBytes.push(parseInt(clean.substr(i, 2), 16));
}
} else {
// Text search
searchBytes = Array.from(new TextEncoder().encode(q));
}
if (searchBytes.length === 0) return;
let foundOffset = -1;
const startOffset = hexEditorState.activeOffset + 1;
for (let i = startOffset; i <= bytes.length - searchBytes.length; i++) {
let match = true;
for (let j = 0; j < searchBytes.length; j++) {
if (bytes[i + j] !== searchBytes[j]) {
match = false;
break;
}
}
if (match) {
foundOffset = i;
break;
}
}
if (foundOffset === -1 && startOffset > 0) {
// Wrap around search from beginning
for (let i = 0; i < startOffset; i++) {
let match = true;
for (let j = 0; j < searchBytes.length; j++) {
if (bytes[i + j] !== searchBytes[j]) {
match = false;
break;
}
}
if (match) {
foundOffset = i;
break;
}
}
}
if (foundOffset !== -1) {
selectHexByte(foundOffset);
const targetEl = document.querySelector(`.hex-byte[data-offset="${foundOffset}"]`);
if (targetEl) targetEl.scrollIntoView({ block: 'center' });
showToast(`Found match at offset 0x${foundOffset.toString(16).toUpperCase()}`, 'info');
} else {
showToast('Pattern not found', 'warning');
}
}
function toggleHexInspector() {
const panel = document.getElementById('hexeditor-inspector-panel');
if (panel) {
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
}
}
async function saveHexEditorChanges() {
if (!hexEditorState.filePath || !hexEditorState.buffer) {
showToast('No active file to save', 'warning');
return;
}
if (hexEditorState.modifiedIndices.size === 0) {
showToast('No modifications to save', 'info');
return;
}
const bytes = hexEditorState.buffer;
if (hexEditorState.filePath.startsWith('client://')) {
try {
const fileHandle = await resolveClientFileHandle(hexEditorState.filePath, false);
if (!fileHandle) {
showToast('Client local file not found', 'error');
return;
}
const writable = await fileHandle.createWritable();
await writable.write(bytes);
await writable.close();
hexEditorState.modifiedIndices.clear();
hexEditorState.originalBuffer = new Uint8Array(bytes);
renderHexEditorView();
computeHexHashes();
showToast('Saved modified binary file successfully!', 'success');
refreshAllPanes();
} catch (e) {
showToast('Save error: ' + sanitizeCredentials(String(e)), 'error');
}
return;
}
let binaryString = '';
for (let i = 0; i < bytes.length; i++) {
binaryString += String.fromCharCode(bytes[i]);
}
const base64Content = btoa(binaryString);
try {
const res = await fetch('/api/fs/write', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
path: hexEditorState.filePath,
content: base64Content,
is_base64: true
})
});
if (res.ok) {
hexEditorState.modifiedIndices.clear();
hexEditorState.originalBuffer = new Uint8Array(bytes);
renderHexEditorView();
computeHexHashes();
showToast('Saved modified binary file successfully!', 'success');
refreshAllPanes();
} else {
showToast('Save failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Save error: ' + sanitizeCredentials(String(e)), 'error');
}
}
function mountDockedHexEditor(paneIndex) {
const host = document.getElementById(`docked-hexeditor-host-${paneIndex}`);
const floatingBody = document.querySelector('.floating-hexeditor-window .hexeditor-body');
if (host && floatingBody) {
host.innerHTML = `
<div style="flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden; background: var(--bg-panel);" id="docked-hex-mount-${paneIndex}"></div>
`;
const innerMount = document.getElementById(`docked-hex-mount-${paneIndex}`);
if (innerMount) {
renderHexEditorView(innerMount);
}
}
}
// ==========================================================================
// 🔍 NATIVE DUPLICATE FINDER CORE FUNCTION (#40)
// ==========================================================================
let duplicatesDragInit = false;
let duplicateFinderActivePaneIndex = 0;
let duplicateFinderState = {
scanning: false,
path: '',
totalScanned: 0,
totalWastedBytes: 0,
groups: [],
selectedPaths: new Set()
};
function initDuplicatesDragResize() {
if (duplicatesDragInit) return;
duplicatesDragInit = true;
const win = document.getElementById('floating-duplicates-window');
const header = document.getElementById('duplicates-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_dup_x');
const savedTop = localStorage.getItem('cd_dup_y');
const savedWidth = localStorage.getItem('cd_dup_w');
const savedHeight = localStorage.getItem('cd_dup_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(480, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(400, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_dup_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_dup_y', parseInt(win.style.top, 10));
}
});
}
function setDuplicateFinderPathToActivePane() {
const activePane = App.panes[App.activePaneIndex];
const p = activePane?.path || '/';
const input = document.getElementById('dup-path-input');
if (input) {
input.value = p;
}
duplicateFinderState.path = p;
}
function openDuplicateFinder(targetPath = null, paneIndex = null) {
duplicateFinderActivePaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[duplicateFinderActivePaneIndex];
const scanPath = targetPath || pane?.path || '/';
duplicateFinderState.path = scanPath;
const win = document.getElementById('floating-duplicates-window');
const body = document.getElementById('duplicates-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('duplicates-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initDuplicatesDragResize();
const pathInput = document.getElementById('dup-path-input');
if (pathInput) {
pathInput.value = scanPath;
}
if (duplicateFinderState.groups.length > 0) {
renderDuplicateGroups();
} else {
const container = document.getElementById('duplicates-groups-container');
if (container) {
container.innerHTML = `
<div class="dup-empty-state">
<i data-lucide="copy-check" style="width: 38px; height: 38px; opacity: 0.35; color: var(--accent);"></i>
<div style="font-size: 13px; font-weight: 600; color: var(--text-main);">Configure Scan Path & Filters</div>
<div style="font-size: 11.5px; color: var(--text-muted); max-width: 340px; text-align: center; line-height: 1.4;">
Specify the directory path and criteria filters above, then click <strong>Find Duplicates</strong> to scan.
</div>
</div>
`;
if (window.lucide) lucide.createIcons({ root: container });
updateDuplicateFooter();
}
}
}
function closeFloatingDuplicateFinder() {
const win = document.getElementById('floating-duplicates-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingDuplicateFinder() {
const win = document.getElementById('floating-duplicates-window');
const pill = document.getElementById('duplicates-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingDuplicateFinder() {
openDuplicateFinder(duplicateFinderState.path);
}
function toggleMaximizeDuplicateFinder() {
const win = document.getElementById('floating-duplicates-window');
if (win) win.classList.toggle('maximized');
}
function dockDuplicateFinderToActivePane() {
closeFloatingDuplicateFinder();
App.panes[App.activePaneIndex].dockedTool = 'duplicates';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'duplicates');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
async function startDuplicateScan() {
const pathInput = document.getElementById('dup-path-input');
const path = (pathInput && pathInput.value.trim()) ? pathInput.value.trim() : (duplicateFinderState.path || App.panes[App.activePaneIndex]?.path || '/');
duplicateFinderState.path = path;
const minSizeSelect = document.getElementById('dup-min-size-select');
const minSize = minSizeSelect ? parseInt(minSizeSelect.value, 10) || 0 : 0;
const fileTypeSelect = document.getElementById('dup-file-type-select');
const fileType = fileTypeSelect ? fileTypeSelect.value : 'all';
const includeHidden = !!document.getElementById('dup-include-hidden')?.checked;
const container = document.getElementById('duplicates-groups-container');
duplicateFinderState.scanning = true;
duplicateFinderState.selectedPaths.clear();
if (container) {
container.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); gap: 10px;">
<i data-lucide="loader-2" class="spin" style="width: 32px; height: 32px; color: var(--accent);"></i>
<span style="font-size: 12px; font-weight: 600;">Scanning directory & computing SHA-256 hashes...</span>
</div>
`;
if (window.lucide) lucide.createIcons({ root: container });
}
const scanBtn = document.getElementById('btn-start-dup-scan');
if (scanBtn) {
scanBtn.disabled = true;
scanBtn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 12px; height: 12px;"></i> Finding...';
if (window.lucide) lucide.createIcons({ root: scanBtn });
}
try {
const res = await fetch('/api/tools/duplicates/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
path: path,
paths: [path],
min_size: minSize,
file_type: fileType,
include_hidden: includeHidden
})
});
if (res.ok) {
const data = await res.json();
duplicateFinderState.groups = data.groups || data.duplicate_groups || [];
duplicateFinderState.totalScanned = data.total_files_scanned || 0;
duplicateFinderState.totalWastedBytes = data.reclaimable_bytes || data.total_wasted_bytes || 0;
autoSelectDuplicates('keep_newest');
renderDuplicateGroups();
showToast(`Scanned ${duplicateFinderState.totalScanned} files. Found ${duplicateFinderState.groups.length} duplicate groups (${formatBytes(duplicateFinderState.totalWastedBytes)} wasted)`, 'info');
} else {
showToast('Scan failed: ' + sanitizeCredentials(await res.text()), 'error');
if (container) {
container.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--danger);">Failed to scan duplicates.</div>`;
}
}
} catch (e) {
showToast('Scan error: ' + sanitizeCredentials(String(e)), 'error');
} finally {
duplicateFinderState.scanning = false;
if (scanBtn) {
scanBtn.disabled = false;
scanBtn.innerHTML = '<i data-lucide="search" style="width: 13px; height: 13px;"></i> Find Duplicates';
if (window.lucide) lucide.createIcons({ root: scanBtn });
}
}
}
function autoSelectDuplicates(strategy) {
duplicateFinderState.selectedPaths.clear();
duplicateFinderState.groups.forEach(group => {
if (!group.files || group.files.length <= 1) return;
if (strategy === 'deselect_all') {
return;
} else if (strategy === 'keep_newest') {
let newest = group.files[0];
group.files.forEach(f => {
const m = f.modified_secs || f.modified || 0;
const nm = newest.modified_secs || newest.modified || 0;
if (m > nm) newest = f;
});
group.files.forEach(f => {
if (f.path !== newest.path) duplicateFinderState.selectedPaths.add(f.path);
});
} else if (strategy === 'keep_oldest') {
let oldest = group.files[0];
group.files.forEach(f => {
const m = f.modified_secs || f.modified || 0;
const om = oldest.modified_secs || oldest.modified || 0;
if (m < om) oldest = f;
});
group.files.forEach(f => {
if (f.path !== oldest.path) duplicateFinderState.selectedPaths.add(f.path);
});
} else if (strategy === 'keep_shortest') {
let shortest = group.files[0];
group.files.forEach(f => {
if (f.path.length < shortest.path.length) shortest = f;
});
group.files.forEach(f => {
if (f.path !== shortest.path) duplicateFinderState.selectedPaths.add(f.path);
});
} else if (strategy === 'all_duplicates') {
for (let i = 1; i < group.files.length; i++) {
duplicateFinderState.selectedPaths.add(group.files[i].path);
}
}
});
updateDuplicateCheckboxes();
updateDuplicateFooter();
}
function toggleDuplicateFile(path) {
if (duplicateFinderState.selectedPaths.has(path)) {
duplicateFinderState.selectedPaths.delete(path);
} else {
duplicateFinderState.selectedPaths.add(path);
}
updateDuplicateCheckboxes();
updateDuplicateFooter();
}
function updateDuplicateCheckboxes() {
document.querySelectorAll('.dup-file-checkbox').forEach(cb => {
const p = cb.getAttribute('data-path');
if (p) {
cb.checked = duplicateFinderState.selectedPaths.has(p);
const row = cb.closest('.dup-file-row');
if (row) {
if (cb.checked) row.classList.add('selected');
else row.classList.remove('selected');
}
}
});
}
function updateDuplicateFooter() {
const count = duplicateFinderState.selectedPaths.size;
let wastedSelectedBytes = 0;
duplicateFinderState.groups.forEach(g => {
(g.files || []).forEach(f => {
if (duplicateFinderState.selectedPaths.has(f.path)) {
wastedSelectedBytes += f.size || g.size || g.total_size || 0;
}
});
});
const summaryEl = document.getElementById('dup-status-summary');
const cleanBtn = document.getElementById('btn-execute-dup-clean');
if (summaryEl) {
if (count > 0) {
summaryEl.textContent = `${count} files selected (${formatBytes(wastedSelectedBytes)} to be freed)`;
summaryEl.style.color = 'var(--accent)';
} else {
summaryEl.textContent = `${duplicateFinderState.groups.length} duplicate groups (${formatBytes(duplicateFinderState.totalWastedBytes)} wasted)`;
summaryEl.style.color = 'var(--text-main)';
}
}
if (cleanBtn) {
cleanBtn.disabled = (count === 0);
}
}
function renderDuplicateGroups(container = null) {
const target = container || document.getElementById('duplicates-groups-container');
if (!target) return;
if (duplicateFinderState.groups.length === 0) {
target.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); gap: 8px;">
<i data-lucide="check-circle" style="width: 32px; height: 32px; color: #10b981; opacity: 0.8;"></i>
<span style="font-size: 12px; font-weight: 600;">No duplicate files found in target directory</span>
</div>
`;
if (window.lucide) lucide.createIcons({ root: target });
updateDuplicateFooter();
return;
}
const html = duplicateFinderState.groups.map((group, gIdx) => {
const groupSize = group.size || group.total_size || (group.files?.[0]?.size || 0);
const wastedInGroup = (group.files.length - 1) * groupSize;
const shortHash = (group.hash || '').substring(0, 12);
const fileRows = group.files.map((file, fIdx) => {
const isSelected = duplicateFinderState.selectedPaths.has(file.path);
const isImage = file.is_image || /\.(jpe?g|png|webp|gif|bmp|svg)$/i.test(file.name);
const modSecs = file.modified_secs || file.modified || 0;
const mdate = modSecs > 0 ? new Date(modSecs * 1000).toLocaleString() : '-';
const encodedPath = encodeURIComponent(file.path);
let thumbHtml = '';
if (isImage) {
thumbHtml = `<img src="/api/fs/preview?path=${encodedPath}" loading="lazy" style="width: 24px; height: 24px; object-fit: cover; border-radius: 3px; border: 1px solid var(--border); flex-shrink: 0;" onerror="this.style.display='none'">`;
} else {
thumbHtml = `<i data-lucide="file" style="width: 15px; height: 15px; color: var(--text-dim); flex-shrink: 0;"></i>`;
}
return `
<div class="dup-file-row ${isSelected ? 'selected' : ''}" onclick="toggleDuplicateFile('${escapeHtml(file.path)}')">
<input type="checkbox" class="dup-file-checkbox" data-path="${escapeHtml(file.path)}" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleDuplicateFile('${escapeHtml(file.path)}')">
${thumbHtml}
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px;">
<div style="font-size: 11.5px; font-weight: 600; color: var(--text-main); text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">${escapeHtml(file.name)}</div>
<div style="font-size: 10px; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; overflow: hidden;" title="${escapeHtml(file.path)}">${escapeHtml(file.path)}</div>
</div>
<span style="font-size: 10px; color: var(--text-dim); white-space: nowrap;">${mdate}</span>
<div style="display: flex; gap: 4px; align-items: center;" onclick="event.stopPropagation()">
<button class="btn btn-icon btn-xs" onclick="openContainingFolder('${escapeHtml(file.path)}')" title="Open Containing Folder"><i data-lucide="folder" style="width: 11px; height: 11px;"></i></button>
</div>
</div>
`;
}).join('');
return `
<div class="dup-group-card">
<div class="dup-group-header">
<div style="display: flex; align-items: center; gap: 6px;">
<span class="badge" style="font-weight: 700; font-size: 11px;">${formatBytes(groupSize)}</span>
<span style="font-size: 10.5px; font-weight: 600; color: var(--text-dim);">${group.files.length} identical copies</span>
<span class="badge" style="font-size: 9.5px; opacity: 0.7; font-family: var(--font-mono);">SHA-256: ${shortHash}...</span>
</div>
<span style="font-size: 10.5px; color: var(--accent); font-weight: 600;">+${formatBytes(wastedInGroup)} wasted</span>
</div>
<div class="dup-group-files">
${fileRows}
</div>
</div>
`;
}).join('');
target.innerHTML = html;
if (window.lucide) lucide.createIcons({ root: target });
updateDuplicateFooter();
}
function openContainingFolder(filePath) {
const dirPath = filePath.substring(0, filePath.lastIndexOf('/')) || '/';
const pane = App.panes[App.activePaneIndex];
if (pane) {
navigateToPath(App.activePaneIndex, dirPath);
}
}
async function executeDuplicateClean() {
const selected = Array.from(duplicateFinderState.selectedPaths);
if (selected.length === 0) {
showToast('No duplicate files selected', 'warning');
return;
}
const action = document.getElementById('dup-action-select')?.value || 'trash';
const isTrash = action === 'trash';
const confirmed = await showConfirmDialog({
title: isTrash ? `Move to ${trashName}` : 'Permanently Delete Duplicates',
subtitle: isTrash ? `Relocate selected duplicates` : 'Irreversible cleanup action',
message: isTrash
? `Are you sure you want to move ${selected.length} duplicate file${selected.length === 1 ? '' : 's'} to ${trashName}?`
: `Are you sure you want to permanently delete ${selected.length} duplicate file${selected.length === 1 ? '' : 's'}? This action cannot be undone.`,
type: isTrash ? 'warning' : 'danger',
confirmText: isTrash ? `Move to ${trashName}` : 'Delete Permanently',
cancelText: 'Cancel'
});
if (!confirmed) return;
const cleanBtn = document.getElementById('btn-execute-dup-clean');
if (cleanBtn) {
cleanBtn.disabled = true;
cleanBtn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 12px; height: 12px;"></i> Cleaning...';
}
try {
const res = await fetch('/api/tools/duplicates/clean', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
files: selected,
paths: selected,
action: isTrash ? 'trash' : 'delete',
move_to_trash: isTrash,
permanent: !isTrash
})
});
if (res.ok) {
const data = await res.json();
const cleanedSet = new Set(selected);
// Prune cleaned files from state
duplicateFinderState.groups.forEach(group => {
group.files = group.files.filter(f => !cleanedSet.has(f.path));
});
duplicateFinderState.groups = duplicateFinderState.groups.filter(g => g.files.length > 1);
duplicateFinderState.selectedPaths.clear();
// Recalculate wasted bytes
duplicateFinderState.totalWastedBytes = duplicateFinderState.groups.reduce((acc, g) => {
const sz = g.size || g.total_size || (g.files?.[0]?.size || 0);
return acc + (g.files.length - 1) * sz;
}, 0);
renderDuplicateGroups();
const freed = data.freed_bytes || data.reclaimed_bytes || 0;
const count = data.deleted_files || data.cleaned_count || selected.length;
showToast(`Cleaned ${count} files successfully! Freed ${formatBytes(freed)}.`, 'success');
refreshAllPanes();
} else {
showToast('Clean failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Clean error: ' + sanitizeCredentials(String(e)), 'error');
} finally {
if (cleanBtn) {
cleanBtn.disabled = false;
cleanBtn.innerHTML = '<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i> Clean Selected';
if (window.lucide) lucide.createIcons({ root: cleanBtn });
}
}
}
function mountDockedDuplicateFinder(paneIndex) {
const host = document.getElementById(`docked-duplicates-host-${paneIndex}`);
const body = document.getElementById('duplicates-body');
if (host && body) {
host.appendChild(body);
const pathInput = document.getElementById('dup-path-input');
if (pathInput && (!pathInput.value || pathInput.value === '/')) {
pathInput.value = App.panes[paneIndex]?.path || '/';
duplicateFinderState.path = pathInput.value;
}
}
}
// ==========================================================================
// 🏷️ NATIVE TAG & METADATA EDITOR CORE FUNCTION (#39)
// ==========================================================================
let tagEditorDragInit = false;
let tagEditorActivePaneIndex = 0;
let tagEditorState = {
files: [],
activeFileIndex: -1,
coverArtBase64: null,
coverArtMime: null,
removeCoverArt: false,
isDirty: false
};
function initTagEditorDragResize() {
if (tagEditorDragInit) return;
tagEditorDragInit = true;
const win = document.getElementById('floating-tageditor-window');
const header = document.getElementById('tageditor-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_tag_x');
const savedTop = localStorage.getItem('cd_tag_y');
const savedWidth = localStorage.getItem('cd_tag_w');
const savedHeight = localStorage.getItem('cd_tag_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(500, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(420, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_tag_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_tag_y', parseInt(win.style.top, 10));
}
});
}
function openTagEditor(files = null, paneIndex = null) {
tagEditorActivePaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[tagEditorActivePaneIndex];
const win = document.getElementById('floating-tageditor-window');
const body = document.getElementById('tageditor-body');
if (win && body && !win.contains(body)) {
win.appendChild(body);
}
const pill = document.getElementById('tageditor-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initTagEditorDragResize();
// Populate files
if (Array.isArray(files) && files.length > 0) {
tagEditorState.files = files.map(f => typeof f === 'string' ? { path: f, name: f.split('/').filter(Boolean).pop() || f } : f);
} else if (pane && pane.selected && pane.selected.size > 0) {
tagEditorState.files = Array.from(pane.selected).map(p => {
const entry = (pane.entries || []).find(e => e.path === p);
return {
path: p,
name: entry ? entry.name : (p.split('/').filter(Boolean).pop() || p)
};
});
} else if (pane && Array.isArray(pane.entries) && pane.entries.length > 0) {
const isTagCandidate = (n) => /\.(mp3|flac|ogg|m4a|wav|aac|jpe?g|png|webp|avif)$/i.test(n);
tagEditorState.files = pane.entries.filter(e => !e.is_dir && isTagCandidate(e.name)).map(e => ({
path: e.path,
name: e.name
}));
if (tagEditorState.files.length === 0) {
tagEditorState.files = pane.entries.filter(e => !e.is_dir).slice(0, 50).map(e => ({ path: e.path, name: e.name }));
}
} else {
tagEditorState.files = [];
}
renderTagEditorFileList();
if (tagEditorState.files.length > 0) {
loadTagEditorFile(0);
} else {
resetTagEditorForm();
}
}
function closeFloatingTagEditor() {
const win = document.getElementById('floating-tageditor-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingTagEditor() {
const win = document.getElementById('floating-tageditor-window');
const pill = document.getElementById('tageditor-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingTagEditor() {
openTagEditor(tagEditorState.files);
}
function toggleMaximizeTagEditor() {
const win = document.getElementById('floating-tageditor-window');
if (win) win.classList.toggle('maximized');
}
function dockTagEditorToActivePane() {
closeFloatingTagEditor();
App.panes[App.activePaneIndex].dockedTool = 'tageditor';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'tageditor');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function reloadTagEditorFiles() {
openTagEditor(null, tagEditorActivePaneIndex);
}
function renderTagEditorFileList() {
const listEl = document.getElementById('tageditor-file-list');
const countEl = document.getElementById('tageditor-file-count');
if (countEl) countEl.textContent = tagEditorState.files.length;
if (!listEl) return;
if (tagEditorState.files.length === 0) {
listEl.innerHTML = `<div style="padding: 12px; text-align: center; color: var(--text-dim); font-size: 11px;">No audio or image files found</div>`;
return;
}
listEl.innerHTML = tagEditorState.files.map((f, idx) => {
const isAudio = /\.(mp3|flac|ogg|m4a|wav|aac)$/i.test(f.name);
const icon = isAudio ? 'music' : 'image';
const activeCls = idx === tagEditorState.activeFileIndex ? 'active' : '';
return `
<div class="tageditor-file-item ${activeCls}" onclick="loadTagEditorFile(${idx})">
<i data-lucide="${icon}" style="width: 13px; height: 13px; color: var(--accent); flex-shrink: 0;"></i>
<span style="font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;">${escapeHtml(f.name)}</span>
</div>
`;
}).join('');
if (window.lucide) lucide.createIcons({ root: listEl });
}
async function loadTagEditorFile(index) {
if (index < 0 || index >= tagEditorState.files.length) return;
tagEditorState.activeFileIndex = index;
renderTagEditorFileList();
const file = tagEditorState.files[index];
const badge = document.getElementById('tageditor-active-file-badge');
if (badge) badge.textContent = sanitizeCredentials(file.name);
try {
const res = await fetch(`/api/tools/metadata/read?path=${encodeURIComponent(file.path)}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const data = await res.json();
populateTagEditorForm(data);
} else {
showToast('Failed to read metadata: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Metadata read error: ' + sanitizeCredentials(String(e)), 'error');
}
}
function populateTagEditorForm(data) {
document.getElementById('tag-title-input').value = data.title || '';
document.getElementById('tag-artist-input').value = data.artist || '';
document.getElementById('tag-album-input').value = data.album || '';
document.getElementById('tag-album-artist-input').value = data.album_artist || '';
document.getElementById('tag-year-input').value = data.year || '';
document.getElementById('tag-track-num-input').value = data.track_number || '';
document.getElementById('tag-track-tot-input').value = data.track_total || '';
document.getElementById('tag-genre-input').value = data.genre || '';
document.getElementById('tag-comment-input').value = data.comment || '';
const artPreview = document.getElementById('tageditor-art-preview');
const artPlaceholder = document.getElementById('tageditor-art-placeholder');
const removeBtn = document.getElementById('btn-remove-cover-art');
tagEditorState.coverArtBase64 = null;
tagEditorState.coverArtMime = null;
tagEditorState.removeCoverArt = false;
if (data.has_cover_art && data.cover_art_base64) {
const mime = data.cover_art_mime || 'image/jpeg';
tagEditorState.coverArtBase64 = data.cover_art_base64;
tagEditorState.coverArtMime = mime;
if (artPreview) {
artPreview.src = `data:${mime};base64,${data.cover_art_base64}`;
artPreview.style.display = 'block';
}
if (artPlaceholder) artPlaceholder.style.display = 'none';
if (removeBtn) removeBtn.style.display = 'inline-block';
} else {
if (artPreview) {
artPreview.src = '';
artPreview.style.display = 'none';
}
if (artPlaceholder) artPlaceholder.style.display = 'flex';
if (removeBtn) removeBtn.style.display = 'none';
}
}
function resetTagEditorForm() {
document.getElementById('tag-title-input').value = '';
document.getElementById('tag-artist-input').value = '';
document.getElementById('tag-album-input').value = '';
document.getElementById('tag-album-artist-input').value = '';
document.getElementById('tag-year-input').value = '';
document.getElementById('tag-track-num-input').value = '';
document.getElementById('tag-track-tot-input').value = '';
document.getElementById('tag-genre-input').value = '';
document.getElementById('tag-comment-input').value = '';
const artPreview = document.getElementById('tageditor-art-preview');
const artPlaceholder = document.getElementById('tageditor-art-placeholder');
const removeBtn = document.getElementById('btn-remove-cover-art');
if (artPreview) {
artPreview.src = '';
artPreview.style.display = 'none';
}
if (artPlaceholder) artPlaceholder.style.display = 'flex';
if (removeBtn) removeBtn.style.display = 'none';
}
function handleTagCoverArtSelected(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target.result;
const base64 = dataUrl.split(',')[1];
const mime = file.type || 'image/jpeg';
tagEditorState.coverArtBase64 = base64;
tagEditorState.coverArtMime = mime;
tagEditorState.removeCoverArt = false;
const artPreview = document.getElementById('tageditor-art-preview');
const artPlaceholder = document.getElementById('tageditor-art-placeholder');
const removeBtn = document.getElementById('btn-remove-cover-art');
if (artPreview) {
artPreview.src = dataUrl;
artPreview.style.display = 'block';
}
if (artPlaceholder) artPlaceholder.style.display = 'none';
if (removeBtn) removeBtn.style.display = 'inline-block';
};
reader.readAsDataURL(file);
}
function removeTagCoverArt() {
tagEditorState.coverArtBase64 = null;
tagEditorState.coverArtMime = null;
tagEditorState.removeCoverArt = true;
const artPreview = document.getElementById('tageditor-art-preview');
const artPlaceholder = document.getElementById('tageditor-art-placeholder');
const removeBtn = document.getElementById('btn-remove-cover-art');
if (artPreview) {
artPreview.src = '';
artPreview.style.display = 'none';
}
if (artPlaceholder) artPlaceholder.style.display = 'flex';
if (removeBtn) removeBtn.style.display = 'none';
}
async function saveTagEditorChanges() {
if (tagEditorState.activeFileIndex < 0 || tagEditorState.activeFileIndex >= tagEditorState.files.length) {
showToast('No active file selected to save', 'warning');
return;
}
const file = tagEditorState.files[tagEditorState.activeFileIndex];
const payload = {
path: file.path,
title: document.getElementById('tag-title-input').value || null,
artist: document.getElementById('tag-artist-input').value || null,
album: document.getElementById('tag-album-input').value || null,
album_artist: document.getElementById('tag-album-artist-input').value || null,
year: document.getElementById('tag-year-input').value ? parseInt(document.getElementById('tag-year-input').value, 10) : null,
track_number: document.getElementById('tag-track-num-input').value ? parseInt(document.getElementById('tag-track-num-input').value, 10) : null,
track_total: document.getElementById('tag-track-tot-input').value ? parseInt(document.getElementById('tag-track-tot-input').value, 10) : null,
genre: document.getElementById('tag-genre-input').value || null,
comment: document.getElementById('tag-comment-input').value || null,
cover_art_base64: tagEditorState.coverArtBase64 || null,
remove_cover_art: tagEditorState.removeCoverArt
};
try {
const res = await fetch('/api/tools/metadata/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
showToast('Saved metadata tags successfully!', 'success');
refreshAllPanes();
} else {
showToast('Save metadata failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Save error: ' + sanitizeCredentials(String(e)), 'error');
}
}
async function applyBatchTagField(fieldName) {
if (tagEditorState.files.length === 0) return;
let value = null;
if (fieldName === 'artist') value = document.getElementById('tag-artist-input').value;
else if (fieldName === 'album') value = document.getElementById('tag-album-input').value;
else if (fieldName === 'album_artist') value = document.getElementById('tag-album-artist-input').value;
else if (fieldName === 'year') value = parseInt(document.getElementById('tag-year-input').value, 10) || null;
else if (fieldName === 'genre') value = document.getElementById('tag-genre-input').value;
if (!value) {
showToast(`Enter a ${fieldName} before applying to all files`, 'warning');
return;
}
const updates = tagEditorState.files.map(f => {
const req = { path: f.path };
req[fieldName] = value;
return req;
});
try {
const res = await fetch('/api/tools/metadata/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ updates })
});
if (res.ok) {
const data = await res.json();
showToast(`Applied ${fieldName} across ${data.updated_files} files!`, 'success');
refreshAllPanes();
} else {
showToast('Batch apply failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Batch error: ' + sanitizeCredentials(String(e)), 'error');
}
}
async function applyBatchAutoTrackNumbers() {
if (tagEditorState.files.length === 0) return;
const total = tagEditorState.files.length;
const updates = tagEditorState.files.map((f, idx) => ({
path: f.path,
track_number: idx + 1,
track_total: total
}));
try {
const res = await fetch('/api/tools/metadata/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({ updates })
});
if (res.ok) {
const data = await res.json();
showToast(`Auto-numbered ${data.updated_files} tracks sequentially (1..${total})`, 'success');
loadTagEditorFile(tagEditorState.activeFileIndex);
refreshAllPanes();
} else {
showToast('Auto-number failed: ' + sanitizeCredentials(await res.text()), 'error');
}
} catch (e) {
showToast('Auto-number error: ' + sanitizeCredentials(String(e)), 'error');
}
}
function mountDockedTagEditor(paneIndex) {
const host = document.getElementById(`docked-tageditor-host-${paneIndex}`);
const body = document.getElementById('tageditor-body');
if (host && body) {
host.appendChild(body);
}
}
// ==========================================================================
// 📜 NATIVE LOG VIEWER CORE FUNCTION (#35)
// ==========================================================================
let logViewerDragInit = false;
let logViewerActivePaneIndex = 0;
let logViewerState = {
filePath: '',
lines: 500,
filter: '',
level: 'ALL',
invert: false,
isFollowing: false,
followTimer: null,
filterDebounceTimer: null
};
function initLogViewerDragResize() {
if (logViewerDragInit) return;
logViewerDragInit = true;
const win = document.getElementById('floating-logviewer-window');
const header = document.getElementById('logviewer-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_log_x');
const savedTop = localStorage.getItem('cd_log_y');
const savedWidth = localStorage.getItem('cd_log_w');
const savedHeight = localStorage.getItem('cd_log_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(500, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(400, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_log_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_log_y', parseInt(win.style.top, 10));
}
});
}
function openLogViewer(filePath = null, paneIndex = null) {
logViewerActivePaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[logViewerActivePaneIndex];
let targetLog = filePath;
if (!targetLog) {
if (pane && pane.selected && pane.selected.size > 0) {
targetLog = Array.from(pane.selected)[0];
} else if (pane && Array.isArray(pane.entries)) {
const logEntry = pane.entries.find(e => !e.is_dir && /\.(log|out|err|txt)$/i.test(e.name));
if (logEntry) targetLog = logEntry.path;
else if (pane.entries.length > 0) targetLog = pane.entries.find(e => !e.is_dir)?.path;
}
}
logViewerState.filePath = targetLog || '/var/log/syslog';
const win = document.getElementById('floating-logviewer-window');
const toolbar = document.querySelector('.logviewer-toolbar');
const consoleEl = document.getElementById('logviewer-console');
if (win) {
if (toolbar && !win.contains(toolbar)) win.appendChild(toolbar);
if (consoleEl && !win.contains(consoleEl)) win.appendChild(consoleEl);
}
const pill = document.getElementById('logviewer-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initLogViewerDragResize();
const fileBadge = document.getElementById('logviewer-file-badge');
if (fileBadge) {
const fileName = logViewerState.filePath.split('/').filter(Boolean).pop() || logViewerState.filePath;
fileBadge.textContent = sanitizeCredentials(fileName);
fileBadge.title = logViewerState.filePath;
}
fetchLogViewerTail();
}
function closeFloatingLogViewer() {
if (logViewerState.followTimer) {
clearInterval(logViewerState.followTimer);
logViewerState.followTimer = null;
logViewerState.isFollowing = false;
}
const win = document.getElementById('floating-logviewer-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingLogViewer() {
const win = document.getElementById('floating-logviewer-window');
const pill = document.getElementById('logviewer-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingLogViewer() {
openLogViewer(logViewerState.filePath);
}
function toggleMaximizeLogViewer() {
const win = document.getElementById('floating-logviewer-window');
if (win) win.classList.toggle('maximized');
}
function dockLogViewerToActivePane() {
closeFloatingLogViewer();
App.panes[App.activePaneIndex].dockedTool = 'logviewer';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'logviewer');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
async function fetchLogViewerTail() {
const path = logViewerState.filePath;
if (!path) return;
const linesSelect = document.getElementById('logviewer-lines-select');
if (linesSelect) logViewerState.lines = parseInt(linesSelect.value, 10) || 500;
const url = `/api/tools/logviewer/tail?path=${encodeURIComponent(path)}&lines=${logViewerState.lines}&filter=${encodeURIComponent(logViewerState.filter || '')}&level=${encodeURIComponent(logViewerState.level || 'ALL')}&invert=${logViewerState.invert}`;
try {
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) {
const data = await res.json();
renderLogViewerConsole(data);
} else {
const consoleEl = document.getElementById('logviewer-console');
if (consoleEl) {
consoleEl.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--danger);">Failed to read log file: ${escapeHtml(await res.text())}</div>`;
}
}
} catch (e) {
const consoleEl = document.getElementById('logviewer-console');
if (consoleEl) {
consoleEl.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--danger);">Log reader error: ${escapeHtml(String(e))}</div>`;
}
}
}
function renderLogViewerConsole(data) {
const consoleEl = document.getElementById('logviewer-console');
if (!consoleEl) return;
const errBadge = document.getElementById('log-count-err');
const warnBadge = document.getElementById('log-count-warn');
if (errBadge) errBadge.textContent = data.level_counts?.error || 0;
if (warnBadge) warnBadge.textContent = data.level_counts?.warn || 0;
if (!data.lines || data.lines.length === 0) {
consoleEl.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--text-dim);">No matching log entries found</div>`;
return;
}
const linesHtml = data.lines.map(line => {
const lvl = (line.level || 'INFO').toUpperCase();
const badgeCls = `log-badge log-badge-${lvl.toLowerCase()}`;
const tsHtml = line.timestamp ? `<span class="log-timestamp">${escapeHtml(line.timestamp)}</span>` : '';
return `
<div class="log-line">
<span class="log-line-num">${line.line_number}</span>
<span class="${badgeCls}">${lvl}</span>
${tsHtml}
<span class="log-text">${escapeHtml(line.message || line.raw)}</span>
</div>
`;
}).join('');
consoleEl.innerHTML = linesHtml;
consoleEl.scrollTop = consoleEl.scrollHeight;
}
function toggleLogFollow() {
const btn = document.getElementById('btn-log-follow');
if (logViewerState.isFollowing) {
logViewerState.isFollowing = false;
if (logViewerState.followTimer) {
clearInterval(logViewerState.followTimer);
logViewerState.followTimer = null;
}
if (btn) {
btn.classList.remove('btn-accent');
btn.innerHTML = '<i data-lucide="play" style="width: 11px; height: 11px;"></i> Follow';
if (window.lucide) lucide.createIcons({ root: btn });
}
} else {
logViewerState.isFollowing = true;
logViewerState.followTimer = setInterval(() => {
fetchLogViewerTail();
}, 1500);
if (btn) {
btn.classList.add('btn-accent');
btn.innerHTML = '<i data-lucide="pause" style="width: 11px; height: 11px;"></i> Following...';
if (window.lucide) lucide.createIcons({ root: btn });
}
}
}
function setLogLevelFilter(level) {
logViewerState.level = level;
document.querySelectorAll('.log-level-btn').forEach(btn => btn.classList.remove('active'));
if (level === 'ALL') document.getElementById('btn-log-lvl-all')?.classList.add('active');
else if (level === 'ERROR') document.getElementById('btn-log-lvl-err')?.classList.add('active');
else if (level === 'WARN') document.getElementById('btn-log-lvl-warn')?.classList.add('active');
fetchLogViewerTail();
}
function handleLogViewerFilterInput() {
if (logViewerState.filterDebounceTimer) clearTimeout(logViewerState.filterDebounceTimer);
logViewerState.filterDebounceTimer = setTimeout(() => {
const input = document.getElementById('logviewer-filter-input');
if (input) {
logViewerState.filter = input.value.trim();
fetchLogViewerTail();
}
}, 250);
}
function toggleLogViewerInvert() {
logViewerState.invert = !logViewerState.invert;
const btn = document.getElementById('btn-logviewer-invert');
if (btn) {
if (logViewerState.invert) btn.classList.add('btn-accent');
else btn.classList.remove('btn-accent');
}
fetchLogViewerTail();
}
function mountDockedLogViewer(paneIndex) {
const host = document.getElementById(`docked-logviewer-host-${paneIndex}`);
const toolbar = document.querySelector('.logviewer-toolbar');
const consoleEl = document.getElementById('logviewer-console');
if (host && consoleEl) {
if (toolbar) host.appendChild(toolbar);
host.appendChild(consoleEl);
fetchLogViewerTail();
}
}
// ==========================================
// 🧊 3D CAD STUDIO & MODEL VIEWER (STL, OBJ, GLTF, 3MF, STEP, DXF, PLY)
// ==========================================
let cadStudioDragInit = false;
let cadStudioActivePaneIndex = 0;
let cadStudioState = {
filePath: null,
isDocked: false,
scene: null,
camera: null,
renderer: null,
animId: null,
currentMesh: null,
currentEdges: null,
currentLineObj: null,
gridHelper: null,
axesHelper: null,
shadingMode: 'solid',
materialTheme: 'amber-charcoal',
showGrid: true,
showAxes: true,
showEdges: false,
isTurntable: false,
isSlicing: false,
sliceAxis: 'z',
slicePercent: 100,
clipPlane: null,
modelBounds: null,
stats: {
triangles: 0,
vertices: 0,
volumeCm3: 0,
surfaceAreaCm2: 0,
weightGrams: 0,
widthMm: 0,
heightMm: 0,
depthMm: 0
},
orbit: {
target: { x: 0, y: 0, z: 0 },
radius: 100,
theta: Math.PI / 4,
phi: Math.PI / 3,
isDragging: false,
dragButton: 0,
startX: 0,
startY: 0,
startTheta: 0,
startPhi: 0,
startRadius: 100,
startTarget: { x: 0, y: 0, z: 0 }
}
};
function initCadDragResize() {
if (cadStudioDragInit) return;
cadStudioDragInit = true;
const win = document.getElementById('floating-cad-window');
const header = document.getElementById('cad-header');
if (!win || !header) return;
const savedLeft = localStorage.getItem('cd_cad_x');
const savedTop = localStorage.getItem('cd_cad_y');
const savedWidth = localStorage.getItem('cd_cad_w');
const savedHeight = localStorage.getItem('cd_cad_h');
if (savedLeft && savedTop && window.innerWidth > 1024) {
win.style.left = `${Math.min(window.innerWidth - 100, Math.max(0, parseInt(savedLeft, 10)))}px`;
win.style.top = `${Math.min(window.innerHeight - 60, Math.max(35, parseInt(savedTop, 10)))}px`;
}
if (savedWidth && window.innerWidth > 1024) win.style.width = `${Math.min(window.innerWidth - 20, Math.max(500, parseInt(savedWidth, 10)))}px`;
if (savedHeight && window.innerWidth > 1024) win.style.height = `${Math.min(window.innerHeight - 40, Math.max(400, parseInt(savedHeight, 10)))}px`;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let winStartX = 0, winStartY = 0;
header.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('input')) return;
if (win.classList.contains('maximized')) return;
isDragging = true;
bringFloatingWindowToFront(win);
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartX = rect.left;
winStartY = rect.top;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'move';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = Math.max(0, Math.min(window.innerWidth - 100, winStartX + dx));
const newY = Math.max(35, Math.min(window.innerHeight - 60, winStartY + dy));
win.style.left = `${newX}px`;
win.style.top = `${newY}px`;
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.left) localStorage.setItem('cd_cad_x', parseInt(win.style.left, 10));
if (win.style.top) localStorage.setItem('cd_cad_y', parseInt(win.style.top, 10));
}
});
// Corner Resize
const corner = document.getElementById('cad-resize-corner');
if (corner) {
let isResizing = false;
let resizeStartX = 0, resizeStartY = 0;
let winStartW = 0, winStartH = 0;
corner.addEventListener('mousedown', (e) => {
if (window.innerWidth <= 1024) return;
e.stopPropagation();
isResizing = true;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
const rect = win.getBoundingClientRect();
winStartW = rect.width;
winStartH = rect.height;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'se-resize';
});
window.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const dw = e.clientX - resizeStartX;
const dh = e.clientY - resizeStartY;
const newW = Math.max(480, Math.min(window.innerWidth - 20, winStartW + dw));
const newH = Math.max(380, Math.min(window.innerHeight - 40, winStartH + dh));
win.style.width = `${newW}px`;
win.style.height = `${newH}px`;
resizeCadViewport();
});
window.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (win.style.width) localStorage.setItem('cd_cad_w', parseInt(win.style.width, 10));
if (win.style.height) localStorage.setItem('cd_cad_h', parseInt(win.style.height, 10));
}
});
}
}
function openCadStudio(filePath = null, paneIndex = null) {
cadStudioActivePaneIndex = (paneIndex !== null && paneIndex !== undefined) ? paneIndex : App.activePaneIndex;
const pane = App.panes[cadStudioActivePaneIndex];
let targetModel = filePath;
if (!targetModel) {
if (pane && pane.selected && pane.selected.size > 0) {
const selected = Array.from(pane.selected).find(p => isCadOr3dExtension(p));
if (selected) targetModel = selected;
}
if (!targetModel && pane && Array.isArray(pane.entries)) {
const cadEntry = pane.entries.find(e => !e.is_dir && isCadOr3dExtension(e.name));
if (cadEntry) targetModel = cadEntry.path;
}
}
cadStudioState.filePath = targetModel;
const win = document.getElementById('floating-cad-window');
const toolbar = document.getElementById('cad-toolbar');
const viewport = document.getElementById('cad-viewport-container');
if (win) {
if (toolbar && !win.contains(toolbar)) win.appendChild(toolbar);
if (viewport && !win.contains(viewport)) win.appendChild(viewport);
}
const pill = document.getElementById('cad-pill');
if (pill) pill.style.display = 'none';
if (win) {
if (window.innerWidth <= 1024) {
win.style.left = '';
win.style.top = '';
win.style.width = '';
win.style.height = '';
}
win.style.display = 'flex';
bringFloatingWindowToFront(win);
}
initCadDragResize();
const fileBadge = document.getElementById('cad-file-badge');
if (fileBadge) {
if (targetModel) {
const fileName = targetModel.split('/').filter(Boolean).pop() || targetModel;
fileBadge.textContent = sanitizeCredentials(fileName);
fileBadge.title = targetModel;
} else {
fileBadge.textContent = 'No model selected';
fileBadge.title = '';
}
}
initCadScene();
setTimeout(resizeCadViewport, 50);
if (targetModel) {
loadCadModel(targetModel);
}
}
function closeFloatingCadStudio() {
const win = document.getElementById('floating-cad-window');
if (win) win.style.display = 'none';
}
function minimizeFloatingCadStudio() {
const win = document.getElementById('floating-cad-window');
const pill = document.getElementById('cad-pill');
if (win) win.style.display = 'none';
if (pill) {
pill.style.display = 'flex';
if (window.lucide) lucide.createIcons();
}
}
function restoreFloatingCadStudio() {
openCadStudio(cadStudioState.filePath);
}
function toggleMaximizeCadStudio() {
const win = document.getElementById('floating-cad-window');
if (win) {
win.classList.toggle('maximized');
setTimeout(resizeCadViewport, 50);
}
}
function dockCadStudioToActivePane() {
closeFloatingCadStudio();
App.panes[App.activePaneIndex].dockedTool = 'cad';
localStorage.setItem(`cd_pane_docked_${App.activePaneIndex}`, 'cad');
rebuildPaneDOM(App.activePaneIndex);
mountDockedTool(App.activePaneIndex);
}
function mountDockedCadStudio(paneIndex) {
const host = document.getElementById(`docked-cad-host-${paneIndex}`);
const toolbar = document.getElementById('cad-toolbar');
const viewport = document.getElementById('cad-viewport-container');
if (host && viewport) {
if (toolbar) host.appendChild(toolbar);
host.appendChild(viewport);
initCadScene();
setTimeout(resizeCadViewport, 50);
if (cadStudioState.filePath) {
loadCadModel(cadStudioState.filePath);
}
}
}
function updateCameraFromOrbit() {
const o = cadStudioState.orbit;
const x = o.target.x + o.radius * Math.sin(o.phi) * Math.sin(o.theta);
const y = o.target.y + o.radius * Math.cos(o.phi);
const z = o.target.z + o.radius * Math.sin(o.phi) * Math.cos(o.theta);
if (cadStudioState.camera) {
cadStudioState.camera.position.set(x, y, z);
cadStudioState.camera.lookAt(o.target.x, o.target.y, o.target.z);
cadStudioState.camera.updateProjectionMatrix();
}
}
function setCadCameraView(preset) {
const o = cadStudioState.orbit;
if (preset === 'iso') {
o.theta = Math.PI / 4;
o.phi = Math.PI / 3;
} else if (preset === 'top') {
o.theta = 0;
o.phi = 0.001;
} else if (preset === 'front') {
o.theta = 0;
o.phi = Math.PI / 2;
} else if (preset === 'right') {
o.theta = Math.PI / 2;
o.phi = Math.PI / 2;
}
updateCameraFromOrbit();
}
function resetCadCamera() {
if (!cadStudioState.modelBounds) return;
const b = cadStudioState.modelBounds;
cadStudioState.orbit.target = { x: 0, y: b.size.y / 2, z: 0 };
const maxDim = Math.max(b.size.x, b.size.y, b.size.z) || 50;
cadStudioState.orbit.radius = maxDim * 2.2;
cadStudioState.orbit.theta = Math.PI / 4;
cadStudioState.orbit.phi = Math.PI / 3;
updateCameraFromOrbit();
}
function setupCadInteraction(canvas) {
if (canvas._hasCadListeners) return;
canvas._hasCadListeners = true;
const o = cadStudioState.orbit;
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
o.isDragging = true;
o.dragButton = e.button;
o.startX = e.clientX;
o.startY = e.clientY;
o.startTheta = o.theta;
o.startPhi = o.phi;
o.startRadius = o.radius;
o.startTarget = { ...o.target };
});
window.addEventListener('mousemove', (e) => {
if (!o.isDragging) return;
const dx = e.clientX - o.startX;
const dy = e.clientY - o.startY;
if (o.dragButton === 0 && !e.shiftKey) {
const rotSpeed = 0.006;
o.theta = o.startTheta - dx * rotSpeed;
o.phi = Math.max(0.01, Math.min(Math.PI - 0.01, o.startPhi - dy * rotSpeed));
updateCameraFromOrbit();
} else {
const panSpeed = (o.radius / 800);
const forward = new THREE.Vector3();
cadStudioState.camera.getWorldDirection(forward);
const right = new THREE.Vector3().crossVectors(forward, cadStudioState.camera.up).normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
o.target.x = o.startTarget.x - right.x * dx * panSpeed + up.x * dy * panSpeed;
o.target.y = o.startTarget.y - right.y * dx * panSpeed + up.y * dy * panSpeed;
o.target.z = o.startTarget.z - right.z * dx * panSpeed + up.z * dy * panSpeed;
updateCameraFromOrbit();
}
});
window.addEventListener('mouseup', () => {
o.isDragging = false;
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
o.radius = Math.max(1, Math.min(50000, o.radius * zoomFactor));
updateCameraFromOrbit();
}, { passive: false });
canvas.addEventListener('dblclick', () => {
resetCadCamera();
});
// Touch Support
let touchStartDist = 0;
let touchStartMid = { x: 0, y: 0 };
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
o.isDragging = true;
o.dragButton = 0;
o.startX = e.touches[0].clientX;
o.startY = e.touches[0].clientY;
o.startTheta = o.theta;
o.startPhi = o.phi;
} else if (e.touches.length === 2) {
o.isDragging = true;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
touchStartDist = Math.hypot(dx, dy);
o.startRadius = o.radius;
o.startTarget = { ...o.target };
touchStartMid = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
};
}
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (!o.isDragging) return;
if (e.touches.length === 1) {
const dx = e.touches[0].clientX - o.startX;
const dy = e.touches[0].clientY - o.startY;
const rotSpeed = 0.008;
o.theta = o.startTheta - dx * rotSpeed;
o.phi = Math.max(0.01, Math.min(Math.PI - 0.01, o.startPhi - dy * rotSpeed));
updateCameraFromOrbit();
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const curDist = Math.hypot(dx, dy);
if (touchStartDist > 0) {
const scale = touchStartDist / curDist;
o.radius = Math.max(1, Math.min(50000, o.startRadius * scale));
}
const curMid = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
};
const midDx = curMid.x - touchStartMid.x;
const midDy = curMid.y - touchStartMid.y;
const panSpeed = (o.radius / 600);
const forward = new THREE.Vector3();
cadStudioState.camera.getWorldDirection(forward);
const right = new THREE.Vector3().crossVectors(forward, cadStudioState.camera.up).normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
o.target.x = o.startTarget.x - right.x * midDx * panSpeed + up.x * midDy * panSpeed;
o.target.y = o.startTarget.y - right.y * midDx * panSpeed + up.y * midDy * panSpeed;
o.target.z = o.startTarget.z - right.z * midDx * panSpeed + up.z * midDy * panSpeed;
updateCameraFromOrbit();
}
}, { passive: true });
canvas.addEventListener('touchend', () => {
o.isDragging = false;
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
function getMaterialColor(theme) {
switch (theme) {
case 'amber-gold': return { color: 0xd97706, roughness: 0.3, metalness: 0.85 };
case 'steel': return { color: 0xc0c5ce, roughness: 0.2, metalness: 0.95 };
case 'bronze': return { color: 0x8c6239, roughness: 0.45, metalness: 0.7 };
case 'cyan': return { color: 0x06b6d4, roughness: 0.3, metalness: 0.4 };
case 'clay': return { color: 0xe2e8f0, roughness: 0.9, metalness: 0.05 };
case 'amber-charcoal':
default:
return { color: 0x475569, roughness: 0.35, metalness: 0.3 };
}
}
function createCadMaterial() {
const mode = cadStudioState.shadingMode;
const themeProps = getMaterialColor(cadStudioState.materialTheme);
let mat;
if (mode === 'wireframe') {
mat = new THREE.MeshBasicMaterial({
color: 0xf59e0b,
wireframe: true
});
} else if (mode === 'normals') {
mat = new THREE.MeshNormalMaterial({
wireframe: false,
side: THREE.DoubleSide
});
} else if (mode === 'matcap') {
mat = new THREE.MeshLambertMaterial({
color: themeProps.color,
side: THREE.DoubleSide
});
} else if (mode === 'smooth') {
mat = new THREE.MeshStandardMaterial({
color: themeProps.color,
roughness: themeProps.roughness,
metalness: themeProps.metalness,
flatShading: false,
side: THREE.DoubleSide
});
} else {
mat = new THREE.MeshStandardMaterial({
color: themeProps.color,
roughness: themeProps.roughness,
metalness: themeProps.metalness,
flatShading: true,
side: THREE.DoubleSide
});
}
if (cadStudioState.isSlicing && cadStudioState.clipPlane) {
mat.clippingPlanes = [cadStudioState.clipPlane];
mat.clipShadows = true;
}
return mat;
}
function setCadShadingMode(mode) {
cadStudioState.shadingMode = mode;
if (cadStudioState.currentMesh) {
cadStudioState.currentMesh.material = createCadMaterial();
}
}
function setCadMaterialTheme(theme) {
cadStudioState.materialTheme = theme;
if (cadStudioState.currentMesh) {
cadStudioState.currentMesh.material = createCadMaterial();
}
}
function toggleCadSlicer() {
cadStudioState.isSlicing = !cadStudioState.isSlicing;
const btn = document.getElementById('btn-cad-slice-toggle');
const controls = document.getElementById('cad-slice-controls');
if (btn) {
if (cadStudioState.isSlicing) btn.classList.add('btn-accent');
else btn.classList.remove('btn-accent');
}
if (controls) {
controls.style.display = cadStudioState.isSlicing ? 'flex' : 'none';
}
updateCadSlicePlane(cadStudioState.slicePercent);
}
function setCadSliceAxis(axis) {
cadStudioState.sliceAxis = axis;
updateCadSlicePlane(cadStudioState.slicePercent);
}
function updateCadSlicePlane(percent) {
cadStudioState.slicePercent = parseFloat(percent);
if (!cadStudioState.renderer) return;
if (!cadStudioState.isSlicing || !cadStudioState.modelBounds) {
cadStudioState.renderer.localClippingEnabled = false;
if (cadStudioState.currentMesh && cadStudioState.currentMesh.material) {
cadStudioState.currentMesh.material.clippingPlanes = [];
}
return;
}
cadStudioState.renderer.localClippingEnabled = true;
const b = cadStudioState.modelBounds;
const p = cadStudioState.slicePercent / 100.0;
let normal = new THREE.Vector3(0, -1, 0);
let constant = 0;
if (cadStudioState.sliceAxis === 'z') {
normal = new THREE.Vector3(0, -1, 0);
const minY = b.min.y;
const maxY = b.max.y;
constant = minY + (maxY - minY) * p;
} else if (cadStudioState.sliceAxis === 'y') {
normal = new THREE.Vector3(0, 0, -1);
const minZ = b.min.z;
const maxZ = b.max.z;
constant = minZ + (maxZ - minZ) * p;
} else if (cadStudioState.sliceAxis === 'x') {
normal = new THREE.Vector3(-1, 0, 0);
const minX = b.min.x;
const maxX = b.max.x;
constant = minX + (maxX - minX) * p;
}
cadStudioState.clipPlane = new THREE.Plane(normal, constant);
if (cadStudioState.currentMesh && cadStudioState.currentMesh.material) {
cadStudioState.currentMesh.material.clippingPlanes = [cadStudioState.clipPlane];
cadStudioState.currentMesh.material.clipShadows = true;
cadStudioState.currentMesh.material.needsUpdate = true;
}
}
function toggleCadGrid() {
cadStudioState.showGrid = !cadStudioState.showGrid;
const btn = document.getElementById('btn-cad-grid');
if (btn) btn.classList.toggle('active', cadStudioState.showGrid);
if (cadStudioState.gridHelper) cadStudioState.gridHelper.visible = cadStudioState.showGrid;
}
function toggleCadAxes() {
cadStudioState.showAxes = !cadStudioState.showAxes;
const btn = document.getElementById('btn-cad-axes');
if (btn) btn.classList.toggle('active', cadStudioState.showAxes);
if (cadStudioState.axesHelper) cadStudioState.axesHelper.visible = cadStudioState.showAxes;
}
function toggleCadEdges() {
cadStudioState.showEdges = !cadStudioState.showEdges;
const btn = document.getElementById('btn-cad-edges');
if (btn) btn.classList.toggle('active', cadStudioState.showEdges);
if (cadStudioState.currentEdges) cadStudioState.currentEdges.visible = cadStudioState.showEdges;
}
function toggleCadTurntable() {
cadStudioState.isTurntable = !cadStudioState.isTurntable;
const btn = document.getElementById('btn-cad-turntable');
if (btn) btn.classList.toggle('active', cadStudioState.isTurntable);
}
function takeCadSnapshot() {
if (!cadStudioState.renderer || !cadStudioState.scene || !cadStudioState.camera) return;
cadStudioState.renderer.render(cadStudioState.scene, cadStudioState.camera);
const dataUrl = cadStudioState.renderer.domElement.toDataURL('image/png');
const a = document.createElement('a');
const baseName = (cadStudioState.filePath || 'cad_model').split('/').pop().replace(/\.[^/.]+$/, '');
a.download = `${baseName}_snapshot.png`;
a.href = dataUrl;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast('Saved snapshot PNG', 'success');
}
function computeCadTelemetry(geometry) {
geometry.computeBoundingBox();
const box = geometry.boundingBox;
const size = new THREE.Vector3();
box.getSize(size);
const posAttr = geometry.attributes.position;
const numVertices = posAttr.count;
const numTriangles = geometry.index ? (geometry.index.count / 3) : (numVertices / 3);
let totalVolumeMm3 = 0;
let totalAreaMm2 = 0;
const p1 = new THREE.Vector3();
const p2 = new THREE.Vector3();
const p3 = new THREE.Vector3();
const cross = new THREE.Vector3();
const numTris = Math.floor(numVertices / 3);
for (let i = 0; i < numTris; i++) {
p1.fromBufferAttribute(posAttr, i * 3);
p2.fromBufferAttribute(posAttr, i * 3 + 1);
p3.fromBufferAttribute(posAttr, i * 3 + 2);
cross.crossVectors(p2, p3);
totalVolumeMm3 += p1.dot(cross) / 6.0;
const edge1 = new THREE.Vector3().subVectors(p2, p1);
const edge2 = new THREE.Vector3().subVectors(p3, p1);
totalAreaMm2 += edge1.cross(edge2).length() * 0.5;
}
const volCm3 = Math.max(0, Math.abs(totalVolumeMm3) / 1000.0);
const plaWeightGrams = volCm3 * 1.24;
cadStudioState.stats = {
triangles: Math.round(numTriangles),
vertices: numVertices,
volumeCm3: volCm3,
surfaceAreaCm2: totalAreaMm2 / 100.0,
weightGrams: plaWeightGrams,
widthMm: size.x,
heightMm: size.y,
depthMm: size.z
};
updateCadHudTelemetry();
}
function updateCadHudTelemetry() {
const dimEl = document.getElementById('cad-dim-val');
const triEl = document.getElementById('cad-stat-triangles');
const vertEl = document.getElementById('cad-stat-vertices');
const volEl = document.getElementById('cad-stat-volume');
const wtEl = document.getElementById('cad-stat-weight');
const s = cadStudioState.stats;
if (dimEl) dimEl.textContent = `${(s.widthMm || 0).toFixed(1)} × ${(s.heightMm || 0).toFixed(1)} × ${(s.depthMm || 0).toFixed(1)} mm`;
if (triEl) triEl.textContent = Number(s.triangles || 0).toLocaleString();
if (vertEl) vertEl.textContent = Number(s.vertices || 0).toLocaleString();
if (volEl) volEl.textContent = `${(s.volumeCm3 || 0).toFixed(1)} cm³`;
if (wtEl) wtEl.textContent = `${(s.weightGrams || 0).toFixed(1)} g`;
}
function renderCadLoop() {
if (cadStudioState.isTurntable) {
cadStudioState.orbit.theta += 0.008;
updateCameraFromOrbit();
}
if (cadStudioState.renderer && cadStudioState.scene && cadStudioState.camera) {
cadStudioState.renderer.render(cadStudioState.scene, cadStudioState.camera);
}
cadStudioState.animId = requestAnimationFrame(renderCadLoop);
}
function resizeCadViewport() {
const container = document.getElementById('cad-viewport-container');
const canvas = document.getElementById('cad-webgl-canvas');
if (!container || !canvas || !cadStudioState.renderer || !cadStudioState.camera) return;
const width = container.clientWidth || 600;
const height = container.clientHeight || 450;
cadStudioState.camera.aspect = width / height;
cadStudioState.camera.updateProjectionMatrix();
cadStudioState.renderer.setSize(width, height);
}
function initCadScene() {
if (cadStudioState.renderer) {
resizeCadViewport();
return;
}
if (!window.THREE) {
setTimeout(initCadScene, 100);
return;
}
const container = document.getElementById('cad-viewport-container');
const canvas = document.getElementById('cad-webgl-canvas');
if (!container || !canvas) return;
const width = container.clientWidth || 600;
const height = container.clientHeight || 450;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x121214);
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100000);
camera.position.set(100, 100, 100);
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
preserveDrawingBuffer: true
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.localClippingEnabled = true;
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.2);
hemiLight.position.set(0, 200, 0);
scene.add(hemiLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight1.position.set(150, 250, 150);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xf59e0b, 0.6);
dirLight2.position.set(-150, -100, -150);
scene.add(dirLight2);
const gridHelper = new THREE.GridHelper(200, 20, 0xf59e0b, 0x27272a);
gridHelper.position.y = 0;
scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(30);
scene.add(axesHelper);
cadStudioState.scene = scene;
cadStudioState.camera = camera;
cadStudioState.renderer = renderer;
cadStudioState.gridHelper = gridHelper;
cadStudioState.axesHelper = axesHelper;
setupCadInteraction(canvas);
if (!cadStudioState.animId) {
renderCadLoop();
}
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
resizeCadViewport();
});
ro.observe(container);
}
}
function applyModelToScene(geometry, isLines = false) {
const loadingOverlay = document.getElementById('cad-loading-overlay');
if (loadingOverlay) loadingOverlay.style.display = 'none';
if (!cadStudioState.scene || !geometry) return;
if (cadStudioState.currentMesh) {
cadStudioState.scene.remove(cadStudioState.currentMesh);
cadStudioState.currentMesh = null;
}
if (cadStudioState.currentEdges) {
cadStudioState.scene.remove(cadStudioState.currentEdges);
cadStudioState.currentEdges = null;
}
if (cadStudioState.currentLineObj) {
cadStudioState.scene.remove(cadStudioState.currentLineObj);
cadStudioState.currentLineObj = null;
}
geometry.computeBoundingBox();
const box = geometry.boundingBox;
const size = new THREE.Vector3();
box.getSize(size);
const center = new THREE.Vector3();
box.getCenter(center);
geometry.translate(-center.x, -box.min.y, -center.z);
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const newBox = geometry.boundingBox;
const newSize = new THREE.Vector3();
newBox.getSize(newSize);
cadStudioState.modelBounds = {
min: newBox.min,
max: newBox.max,
size: newSize,
radius: geometry.boundingSphere ? geometry.boundingSphere.radius : Math.max(newSize.x, newSize.y, newSize.z) / 2
};
if (cadStudioState.gridHelper) {
cadStudioState.scene.remove(cadStudioState.gridHelper);
}
const maxFootprint = Math.max(newSize.x, newSize.z, 20);
const gridSize = Math.ceil(maxFootprint * 1.8 / 10) * 10;
const divisions = Math.min(50, Math.max(10, Math.round(gridSize / 10)));
cadStudioState.gridHelper = new THREE.GridHelper(gridSize, divisions, 0xf59e0b, 0x334155);
cadStudioState.gridHelper.position.y = 0;
cadStudioState.gridHelper.visible = cadStudioState.showGrid;
cadStudioState.scene.add(cadStudioState.gridHelper);
if (isLines) {
const lineMat = new THREE.LineBasicMaterial({
color: 0xf59e0b,
linewidth: 1.5
});
cadStudioState.currentLineObj = new THREE.LineSegments(geometry, lineMat);
cadStudioState.scene.add(cadStudioState.currentLineObj);
} else {
const material = createCadMaterial();
cadStudioState.currentMesh = new THREE.Mesh(geometry, material);
cadStudioState.scene.add(cadStudioState.currentMesh);
const edgesGeom = new THREE.EdgesGeometry(geometry, 25);
const edgesMat = new THREE.LineBasicMaterial({
color: 0xf59e0b,
transparent: true,
opacity: 0.7
});
cadStudioState.currentEdges = new THREE.LineSegments(edgesGeom, edgesMat);
cadStudioState.currentEdges.visible = cadStudioState.showEdges;
cadStudioState.scene.add(cadStudioState.currentEdges);
}
computeCadTelemetry(geometry);
resetCadCamera();
}
function parseBinaryStl(buffer) {
const dataView = new DataView(buffer);
const numTriangles = dataView.getUint32(80, true);
const positions = new Float32Array(numTriangles * 9);
const normals = new Float32Array(numTriangles * 9);
let offset = 84;
let posIdx = 0;
let normIdx = 0;
for (let i = 0; i < numTriangles; i++) {
if (offset + 50 > buffer.byteLength) break;
const nx = dataView.getFloat32(offset, true);
const ny = dataView.getFloat32(offset + 4, true);
const nz = dataView.getFloat32(offset + 8, true);
offset += 12;
for (let v = 0; v < 3; v++) {
const vx = dataView.getFloat32(offset, true);
const vy = dataView.getFloat32(offset + 4, true);
const vz = dataView.getFloat32(offset + 8, true);
offset += 12;
positions[posIdx++] = vx;
positions[posIdx++] = vy;
positions[posIdx++] = vz;
normals[normIdx++] = nx;
normals[normIdx++] = ny;
normals[normIdx++] = nz;
}
offset += 2;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
if (normIdx > 0 && (normals[0] !== 0 || normals[1] !== 0 || normals[2] !== 0)) {
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
} else {
geometry.computeVertexNormals();
}
return geometry;
}
function parseAsciiStl(text) {
const positions = [];
const normals = [];
const lines = text.split('\n');
let currentNormal = [0, 1, 0];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('facet normal')) {
const parts = line.split(/\s+/);
if (parts.length >= 4) {
currentNormal = [parseFloat(parts[2]), parseFloat(parts[3]), parseFloat(parts[4])];
}
} else if (line.startsWith('vertex')) {
const parts = line.split(/\s+/);
if (parts.length >= 4) {
positions.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3]));
normals.push(currentNormal[0], currentNormal[1], currentNormal[2]);
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
if (normals.length > 0) {
geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3));
} else {
geometry.computeVertexNormals();
}
return geometry;
}
function parseObj(text) {
const vertices = [];
const normals = [];
const uvs = [];
const outPositions = [];
const outNormals = [];
const outUvs = [];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
const cmd = parts[0];
if (cmd === 'v') {
vertices.push([parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])]);
} else if (cmd === 'vn') {
normals.push([parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])]);
} else if (cmd === 'vt') {
uvs.push([parseFloat(parts[1]), parseFloat(parts[2])]);
} else if (cmd === 'f') {
const faceVerts = [];
for (let j = 1; j < parts.length; j++) {
if (!parts[j]) continue;
const vParts = parts[j].split('/');
const vIdx = parseInt(vParts[0], 10);
const vtIdx = vParts[1] ? parseInt(vParts[1], 10) : null;
const vnIdx = vParts[2] ? parseInt(vParts[2], 10) : null;
faceVerts.push({
v: vIdx > 0 ? vIdx - 1 : vertices.length + vIdx,
vt: vtIdx ? (vtIdx > 0 ? vtIdx - 1 : uvs.length + vtIdx) : null,
vn: vnIdx ? (vnIdx > 0 ? vnIdx - 1 : normals.length + vnIdx) : null
});
}
for (let k = 1; k < faceVerts.length - 1; k++) {
const tri = [faceVerts[0], faceVerts[k], faceVerts[k + 1]];
for (const fv of tri) {
if (vertices[fv.v]) {
outPositions.push(vertices[fv.v][0], vertices[fv.v][1], vertices[fv.v][2]);
}
if (fv.vn !== null && normals[fv.vn]) {
outNormals.push(normals[fv.vn][0], normals[fv.vn][1], normals[fv.vn][2]);
}
if (fv.vt !== null && uvs[fv.vt]) {
outUvs.push(uvs[fv.vt][0], uvs[fv.vt][1]);
}
}
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(outPositions), 3));
if (outNormals.length === outPositions.length && outNormals.length > 0) {
geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(outNormals), 3));
} else {
geometry.computeVertexNormals();
}
return geometry;
}
function parseDxf(text) {
const lines = text.split(/\r?\n/);
const linePositions = [];
const meshPositions = [];
let i = 0;
while (i < lines.length - 1) {
const code = lines[i].trim();
const val = lines[i + 1].trim();
i += 2;
if (code === '0' && val === 'LINE') {
let x1 = 0, y1 = 0, z1 = 0, x2 = 0, y2 = 0, z2 = 0;
while (i < lines.length - 1) {
const c = lines[i].trim();
const v = lines[i + 1].trim();
if (c === '0') break;
if (c === '10') x1 = parseFloat(v);
else if (c === '20') y1 = parseFloat(v);
else if (c === '30') z1 = parseFloat(v);
else if (c === '11') x2 = parseFloat(v);
else if (c === '21') y2 = parseFloat(v);
else if (c === '31') z2 = parseFloat(v);
i += 2;
}
linePositions.push(x1, y1, z1, x2, y2, z2);
} else if (code === '0' && (val === 'LWPOLYLINE' || val === 'POLYLINE')) {
const polyVerts = [];
let isClosed = false;
while (i < lines.length - 1) {
const c = lines[i].trim();
const v = lines[i + 1].trim();
if (c === '0') break;
if (c === '70' && (parseInt(v, 10) & 1) === 1) isClosed = true;
if (c === '10') {
const vx = parseFloat(v);
let vy = 0, vz = 0;
if (i + 2 < lines.length && lines[i + 2].trim() === '20') {
vy = parseFloat(lines[i + 3].trim());
i += 2;
}
if (i + 2 < lines.length && lines[i + 2].trim() === '30') {
vz = parseFloat(lines[i + 3].trim());
i += 2;
}
polyVerts.push([vx, vy, vz]);
}
i += 2;
}
for (let p = 0; p < polyVerts.length - 1; p++) {
linePositions.push(polyVerts[p][0], polyVerts[p][1], polyVerts[p][2],
polyVerts[p + 1][0], polyVerts[p + 1][1], polyVerts[p + 1][2]);
}
if (isClosed && polyVerts.length > 2) {
const last = polyVerts[polyVerts.length - 1];
const first = polyVerts[0];
linePositions.push(last[0], last[1], last[2], first[0], first[1], first[2]);
}
} else if (code === '0' && val === 'CIRCLE') {
let cx = 0, cy = 0, cz = 0, r = 1;
while (i < lines.length - 1) {
const c = lines[i].trim();
const v = lines[i + 1].trim();
if (c === '0') break;
if (c === '10') cx = parseFloat(v);
else if (c === '20') cy = parseFloat(v);
else if (c === '30') cz = parseFloat(v);
else if (c === '40') r = parseFloat(v);
i += 2;
}
const segs = 32;
for (let s = 0; s < segs; s++) {
const a1 = (s / segs) * Math.PI * 2;
const a2 = ((s + 1) / segs) * Math.PI * 2;
linePositions.push(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r, cz,
cx + Math.cos(a2) * r, cy + Math.sin(a2) * r, cz);
}
} else if (code === '0' && val === '3DFACE') {
const v = [];
while (i < lines.length - 1) {
const c = lines[i].trim();
const valStr = lines[i + 1].trim();
if (c === '0') break;
const cNum = parseInt(c, 10);
if (cNum >= 10 && cNum <= 13) {
const idx = cNum - 10;
v[idx] = v[idx] || [0, 0, 0];
v[idx][0] = parseFloat(valStr);
} else if (cNum >= 20 && cNum <= 23) {
const idx = cNum - 20;
v[idx] = v[idx] || [0, 0, 0];
v[idx][1] = parseFloat(valStr);
} else if (cNum >= 30 && cNum <= 33) {
const idx = cNum - 30;
v[idx] = v[idx] || [0, 0, 0];
v[idx][2] = parseFloat(valStr);
}
i += 2;
}
if (v.length >= 3) {
meshPositions.push(v[0][0], v[0][1], v[0][2], v[1][0], v[1][1], v[1][2], v[2][0], v[2][1], v[2][2]);
if (v[3] && (v[3][0] !== v[2][0] || v[3][1] !== v[2][1] || v[3][2] !== v[2][2])) {
meshPositions.push(v[0][0], v[0][1], v[0][2], v[2][0], v[2][1], v[2][2], v[3][0], v[3][1], v[3][2]);
}
}
}
}
if (meshPositions.length > 0) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(meshPositions), 3));
geom.computeVertexNormals();
return { geometry: geom, isLines: false };
} else if (linePositions.length > 0) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return { geometry: geom, isLines: true };
}
return null;
}
function parsePly(textOrBuffer) {
let text = '';
if (typeof textOrBuffer === 'string') {
text = textOrBuffer;
} else {
text = new TextDecoder('utf-8').decode(textOrBuffer);
}
const lines = text.split('\n');
let isPly = false;
let isAscii = false;
let numVerts = 0;
let numFaces = 0;
let headerEnd = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === 'ply') isPly = true;
if (line.startsWith('format ascii')) isAscii = true;
if (line.startsWith('element vertex')) numVerts = parseInt(line.split(/\s+/)[2], 10);
if (line.startsWith('element face')) numFaces = parseInt(line.split(/\s+/)[2], 10);
if (line === 'end_header') {
headerEnd = i + 1;
break;
}
}
if (!isPly || !isAscii) return null;
const vertices = [];
for (let i = headerEnd; i < headerEnd + numVerts; i++) {
if (i >= lines.length) break;
const parts = lines[i].trim().split(/\s+/);
if (parts.length >= 3) {
vertices.push([parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])]);
}
}
const meshPositions = [];
for (let i = headerEnd + numVerts; i < headerEnd + numVerts + numFaces; i++) {
if (i >= lines.length) break;
const parts = lines[i].trim().split(/\s+/);
const count = parseInt(parts[0], 10);
if (count === 3 && parts.length >= 4) {
const i0 = parseInt(parts[1], 10);
const i1 = parseInt(parts[2], 10);
const i2 = parseInt(parts[3], 10);
if (vertices[i0] && vertices[i1] && vertices[i2]) {
meshPositions.push(vertices[i0][0], vertices[i0][1], vertices[i0][2],
vertices[i1][0], vertices[i1][1], vertices[i1][2],
vertices[i2][0], vertices[i2][1], vertices[i2][2]);
}
} else if (count === 4 && parts.length >= 5) {
const i0 = parseInt(parts[1], 10);
const i1 = parseInt(parts[2], 10);
const i2 = parseInt(parts[3], 10);
const i3 = parseInt(parts[4], 10);
if (vertices[i0] && vertices[i1] && vertices[i2] && vertices[i3]) {
meshPositions.push(vertices[i0][0], vertices[i0][1], vertices[i0][2],
vertices[i1][0], vertices[i1][1], vertices[i1][2],
vertices[i2][0], vertices[i2][1], vertices[i2][2]);
meshPositions.push(vertices[i0][0], vertices[i0][1], vertices[i0][2],
vertices[i2][0], vertices[i2][1], vertices[i2][2],
vertices[i3][0], vertices[i3][1], vertices[i3][2]);
}
}
}
if (meshPositions.length > 0) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(meshPositions), 3));
geom.computeVertexNormals();
return geom;
}
return null;
}
function parseStepWireframe(text) {
const linePositions = [];
const pointMap = {};
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const cpMatch = line.match(/^#(\d+)\s*=\s*CARTESIAN_POINT\s*\([^,]*,\s*\(\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*,\s*([-\d.eE+]+)\s*\)\s*\)/i);
if (cpMatch) {
const id = cpMatch[1];
pointMap[id] = [parseFloat(cpMatch[2]), parseFloat(cpMatch[3]), parseFloat(cpMatch[4])];
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const edgeMatch = line.match(/EDGE_CURVE\s*\([^,]*,\s*#(\d+)\s*,\s*#(\d+)/i);
if (edgeMatch) {
const p1 = pointMap[edgeMatch[1]];
const p2 = pointMap[edgeMatch[2]];
if (p1 && p2) {
linePositions.push(p1[0], p1[1], p1[2], p2[0], p2[1], p2[2]);
}
}
}
if (linePositions.length === 0) {
const pts = Object.values(pointMap);
for (let p = 0; p < pts.length - 1; p++) {
linePositions.push(pts[p][0], pts[p][1], pts[p][2], pts[p + 1][0], pts[p + 1][1], pts[p + 1][2]);
}
}
if (linePositions.length > 0) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return { geometry: geom, isLines: true };
}
return null;
}
async function loadCadModel(filePath) {
const loadingOverlay = document.getElementById('cad-loading-overlay');
const loadingText = document.getElementById('cad-loading-text');
if (loadingOverlay) loadingOverlay.style.display = 'flex';
if (loadingText) loadingText.textContent = 'Fetching 3D CAD model...';
try {
const ext = filePath.split('.').pop().toLowerCase();
let textContent = '';
let arrayBuffer = null;
if (filePath.startsWith('client://')) {
const fileHandle = await resolveClientFileHandle(filePath, false);
if (!fileHandle) throw new Error('Client CAD file not found');
const file = await fileHandle.getFile();
if (ext === 'stl') {
arrayBuffer = await file.arrayBuffer();
} else {
textContent = await file.text();
}
} else {
const endpoint = getPaneEndpoint(cadStudioActivePaneIndex);
const url = `${endpoint}/api/fs/download?path=${encodeURIComponent(filePath)}`;
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (!res.ok) {
throw new Error(`Failed to load file: ${res.statusText}`);
}
if (ext === 'stl') {
arrayBuffer = await res.arrayBuffer();
} else {
textContent = await res.text();
}
}
if (loadingText) loadingText.textContent = 'Parsing geometry...';
if (ext === 'stl') {
const buffer = arrayBuffer;
const dataView = new DataView(buffer);
let isBinary = false;
if (buffer.byteLength >= 84) {
const numTriangles = dataView.getUint32(80, true);
const expectedSize = 84 + numTriangles * 50;
if (expectedSize === buffer.byteLength) {
isBinary = true;
}
}
const geom = isBinary ? parseBinaryStl(buffer) : parseAsciiStl(new TextDecoder('utf-8').decode(buffer));
applyModelToScene(geom, false);
} else if (ext === 'obj') {
const geom = parseObj(textContent);
applyModelToScene(geom, false);
} else if (ext === 'dxf') {
const parsed = parseDxf(textContent);
if (parsed) applyModelToScene(parsed.geometry, parsed.isLines);
else throw new Error('No supported entities found in DXF file');
} else if (ext === 'ply') {
const geom = parsePly(textContent);
if (geom) applyModelToScene(geom, false);
else throw new Error('Unsupported PLY format');
} else if (['step', 'stp', 'iges', 'igs'].includes(ext)) {
const parsed = parseStepWireframe(textContent);
if (parsed) applyModelToScene(parsed.geometry, parsed.isLines);
else throw new Error('Could not extract CAD wireframe vertices');
} else {
throw new Error(`Unsupported CAD format: .${ext}`);
}
} catch (err) {
console.error('CAD Load Error:', err);
showToast(`CAD Viewer Error: ${err.message}`, 'error');
if (loadingText) loadingText.textContent = `Error: ${err.message}`;
setTimeout(() => {
if (loadingOverlay) loadingOverlay.style.display = 'none';
}, 2500);
}
}
// ==============================================================================
// 🗑️ Trash Management & Recovery Suite
// ==============================================================================
function isTrashDirectory(path) {
if (!path || typeof path !== 'string') return false;
const p = path.toLowerCase().trim();
return p === 'trash://' ||
p === 'trash:' ||
p === 'trash' ||
p === 'recycle://' ||
p === 'recycle:' ||
p === 'recycle bin' ||
p === 'recyclebin' ||
p === 'recycle_bin' ||
p === 'shell:recyclebinfolder' ||
p.includes('$recycle.bin') ||
p.includes('recycle.bin') ||
p.includes('/.local/share/trash') ||
p.includes('\\.local\\share\\trash') ||
p.includes('/tmp/brum_trash') ||
p.endsWith('/trash/files') ||
p.endsWith('/trash') ||
p.endsWith('\\trash\\files') ||
p.endsWith('\\trash');
}
async function getTrashSummary() {
try {
const isWinNative = isWindowsHost() && App.windowsNativeOps;
const query = new URLSearchParams();
if (typeof isWinNative === 'boolean') query.set('windows_native_ops', isWinNative ? 'true' : 'false');
if (App.customTrashDir) query.set('custom_trash_dir', App.customTrashDir);
const qs = query.toString() ? `?${query.toString()}` : '';
const res = await fetch(`/api/tools/trash/summary${qs}`, {
headers: { 'Authorization': `Bearer ${App.token}` }
});
if (res.ok) return await res.json();
} catch (err) {
console.warn('Failed to load trash summary:', err);
}
return { total_items: 0, total_size: 0, trash_dir: '', files_dir: '', info_dir: '' };
}
async function openNativeRecycleBin() {
try {
const res = await fetch('/api/tools/trash/open-native', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
}
});
if (res.ok) {
showToast('Opened Windows Recycle Bin', 'info');
}
} catch (err) {
console.warn('Could not trigger open-native trash:', err);
}
}
async function restoreSelectedTrashItems(paneIndex) {
const paneIdx = (typeof paneIndex === 'number' && !isNaN(paneIndex)) ? paneIndex : App.activePaneIndex;
const pane = App.panes[paneIdx];
if (!pane) return;
let selectedItems = [];
if (pane.selected && pane.selected.size > 0) {
pane.selected.forEach(p => {
selectedItems.push(p);
const match = pane.entries?.find(e => e.path === p || e.name === p);
if (match) {
if (match.path) selectedItems.push(match.path);
if (match.name) selectedItems.push(match.name);
}
const parts = p.split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
});
}
// If no files are explicitly multi-selected, check if a row is currently highlighted/focused
if (selectedItems.length === 0 && typeof pane.cursorIndex === 'number' && pane.cursorIndex >= 0 && pane.entries && pane.entries[pane.cursorIndex]) {
const cursorEntry = pane.entries[pane.cursorIndex];
if (cursorEntry && cursorEntry.name && cursorEntry.name !== '..') {
if (cursorEntry.path) selectedItems.push(cursorEntry.path);
if (cursorEntry.name) selectedItems.push(cursorEntry.name);
const parts = (cursorEntry.path || '').split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
}
}
// Context item fallback (e.g. from right-click context menu)
if (selectedItems.length === 0 && App.contextItem && (App.contextPaneIndex === undefined || App.contextPaneIndex === paneIdx)) {
if (App.contextItem.path) selectedItems.push(App.contextItem.path);
if (App.contextItem.name) selectedItems.push(App.contextItem.name);
const parts = (App.contextItem.path || '').split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
}
const uniqueItems = Array.from(new Set(selectedItems.filter(Boolean)));
let selectedNames = uniqueItems.length > 0 ? uniqueItems : null;
if (!selectedNames) {
const validEntries = pane.entries ? pane.entries.filter(e => e.name !== '..') : [];
const totalCount = validEntries.length;
if (totalCount === 0) {
showToast(`${getTrashDisplayName(true)} is empty`, 'warning');
return;
}
const confirmed = await showConfirmDialog({
title: 'Restore All Items',
subtitle: `${getTrashDisplayName(true)} restoration`,
message: `Restore all ${totalCount} item${totalCount === 1 ? '' : 's'} in the ${getTrashDisplayName(true)} to their original locations?`,
type: 'warning',
confirmText: 'Restore All',
cancelText: 'Cancel'
});
if (!confirmed) {
return;
}
selectedNames = null; // Backend interprets null/empty as restore all
}
const isWinNative = isWindowsHost() && App.windowsNativeOps;
const countDesc = selectedNames ? (pane.selected && pane.selected.size > 0 ? `${pane.selected.size} item(s)` : '1 item') : 'all items';
showToast(`Restoring ${countDesc} from ${getTrashDisplayName(false)}...`, 'info');
try {
const res = await fetch('/api/tools/trash/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
items: selectedNames,
custom_trash_dir: App.customTrashDir || undefined,
windows_native_ops: isWinNative
})
});
if (res.ok) {
const result = await res.json();
if (result.success) {
showToast(`Restored ${result.affected_count} item${result.affected_count === 1 ? '' : 's'} to original location`, 'success');
} else {
const errs = result.errors ? result.errors.join('; ') : 'Some items could not be restored';
showToast(`Restored ${result.affected_count} items with warnings: ${errs}`, 'warning');
}
if (pane.selected) pane.selected.clear();
refreshAllPanes();
} else {
const err = await res.text();
showToast(`Restore failed: ${err}`, 'error');
}
} catch (err) {
showToast(`Restore error: ${err.message}`, 'error');
}
}
async function deleteSelectedTrashItems(paneIndex) {
const paneIdx = (typeof paneIndex === 'number' && !isNaN(paneIndex)) ? paneIndex : App.activePaneIndex;
const pane = App.panes[paneIdx];
if (!pane) return;
let selectedItems = [];
if (pane.selected && pane.selected.size > 0) {
pane.selected.forEach(p => {
selectedItems.push(p);
const match = pane.entries?.find(e => e.path === p || e.name === p);
if (match) {
if (match.path) selectedItems.push(match.path);
if (match.name) selectedItems.push(match.name);
}
const parts = p.split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
});
}
// If no files are explicitly multi-selected, check if a row is currently highlighted/focused
if (selectedItems.length === 0 && typeof pane.cursorIndex === 'number' && pane.cursorIndex >= 0 && pane.entries && pane.entries[pane.cursorIndex]) {
const cursorEntry = pane.entries[pane.cursorIndex];
if (cursorEntry && cursorEntry.name && cursorEntry.name !== '..') {
if (cursorEntry.path) selectedItems.push(cursorEntry.path);
if (cursorEntry.name) selectedItems.push(cursorEntry.name);
const parts = (cursorEntry.path || '').split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
}
}
// Context item fallback (e.g. from right-click context menu)
if (selectedItems.length === 0 && App.contextItem && (App.contextPaneIndex === undefined || App.contextPaneIndex === paneIdx)) {
if (App.contextItem.path) selectedItems.push(App.contextItem.path);
if (App.contextItem.name) selectedItems.push(App.contextItem.name);
const parts = (App.contextItem.path || '').split(/[\/\\]/);
const last = parts[parts.length - 1];
if (last) selectedItems.push(last);
}
const uniqueItems = Array.from(new Set(selectedItems.filter(Boolean)));
if (uniqueItems.length === 0) {
showToast('Select one or more items to permanently delete', 'warning');
return;
}
const isWinNative = isWindowsHost() && App.windowsNativeOps;
const countDesc = pane.selected && pane.selected.size > 0 ? `${pane.selected.size} selected item(s)` : '1 item';
const confirmed = await showConfirmDialog({
title: 'Permanently Delete',
subtitle: `${getTrashDisplayName(true)} cleanup`,
message: `Permanently delete ${countDesc}? This action cannot be undone.`,
type: 'danger',
confirmText: 'Delete Permanently',
cancelText: 'Cancel'
});
if (!confirmed) {
return;
}
try {
const res = await fetch('/api/tools/trash/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
items: uniqueItems,
custom_trash_dir: App.customTrashDir || undefined,
windows_native_ops: isWinNative
})
});
if (res.ok) {
const result = await res.json();
showToast(`Permanently deleted ${result.affected_count} item${result.affected_count === 1 ? '' : 's'}`, 'success');
if (pane.selected) pane.selected.clear();
refreshPane(paneIdx);
} else {
const err = await res.text();
showToast(`Delete failed: ${err}`, 'error');
}
} catch (err) {
showToast(`Delete error: ${err.message}`, 'error');
}
}
async function promptEmptyTrash(paneIndex) {
const isWinNative = isWindowsHost() && App.windowsNativeOps;
const confirmed = await showConfirmDialog({
title: `Empty ${getTrashDisplayName(true)}`,
subtitle: 'Irreversible deletion',
message: isWinNative
? 'Permanently empty all items from the Windows Recycle Bin? This action cannot be undone.'
: `Permanently delete all items and empty the ${getTrashDisplayName(true)}? This action cannot be undone.`,
type: 'danger',
confirmText: 'Empty ' + getTrashDisplayName(false),
cancelText: 'Cancel'
});
if (!confirmed) {
return;
}
try {
const res = await fetch('/api/tools/trash/empty', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${App.token}`
},
body: JSON.stringify({
custom_trash_dir: App.customTrashDir || undefined,
windows_native_ops: isWinNative
})
});
if (res.ok) {
const result = await res.json();
const freedStr = result.freed_bytes ? ` (${formatBytes(result.freed_bytes)} freed)` : '';
showToast(`${getTrashDisplayName(true)} emptied${freedStr}`, 'success');
if (typeof paneIndex === 'number') refreshPane(paneIndex);
else refreshAllPanes();
} else {
const err = await res.text();
showToast(`Empty ${getTrashDisplayName(true)} failed: ${err}`, 'error');
}
} catch (err) {
showToast(`Empty ${getTrashDisplayName(true)} error: ${err.message}`, 'error');
}
}