diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index f936499..a6e77aa 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -1674,9 +1674,11 @@ async function applyThemeOverridesToOpenBoards() { return; } + const UNIFIED_BOARD_PATH = '__unified__'; const sourceOverrides = getBoardThemeOverrides(); const sourceBoard = window.boardRoot; - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : [sourceBoard]; + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : [sourceBoard]; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); const targets = Array.isArray(openBoards) ? openBoards : []; for (const boardPath of targets) { @@ -1701,9 +1703,11 @@ async function applyNotificationSettingsToOpenBoards() { return; } + const UNIFIED_BOARD_PATH = '__unified__'; const sourceNotifications = getBoardNotificationSettings(); const sourceBoard = window.boardRoot; - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : [sourceBoard]; + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : [sourceBoard]; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); const targets = Array.isArray(openBoards) ? openBoards : []; for (const boardPath of targets) { @@ -1886,10 +1890,11 @@ function scheduleBoardLabelSettingsSave() { function persistBoardSettings() { const state = getBoardLabelState(); + const UNIFIED_BOARD_PATH = '__unified__'; state.settingsSaveInFlight = state.settingsSaveInFlight .then(async () => { - if (!window.boardRoot) { + if (!window.boardRoot || window.boardRoot === UNIFIED_BOARD_PATH) { return; } @@ -2322,6 +2327,7 @@ function resetBoardLabelFilter() { } async function ensureBoardLabelsLoaded() { + const UNIFIED_BOARD_PATH = '__unified__'; if (!window.boardRoot) { const state = getBoardLabelState(); state.importSummaryBoardRoot = ''; @@ -2339,6 +2345,33 @@ async function ensureBoardLabelsLoaded() { return; } + if (window.boardRoot === UNIFIED_BOARD_PATH) { + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); + const allLabels = []; + const seenLabelIds = new Set(); + + for (const boardPath of openBoards) { + try { + const settings = await window.board.readBoardSettings(boardPath); + if (settings && Array.isArray(settings.labels)) { + for (const label of settings.labels) { + if (!seenLabelIds.has(label.id)) { + allLabels.push(label); + seenLabelIds.add(label.id); + } + } + } + } catch (e) { + console.warn(`Failed to load labels for ${boardPath}`, e); + } + } + setBoardLabels(allLabels); + // Use default theme for unified view for now + applyColorSchemeById('default', { renderControls: false }); + return; + } + const settings = await window.board.readBoardSettings(window.boardRoot); setBoardLabels(settings.labels || []); const loadedSchemeId = settings.colorScheme || ''; diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index ca20002..66a30f3 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -1,6 +1,7 @@ const OPEN_BOARDS_STORAGE_KEY = 'openBoardPaths'; const ACTIVE_BOARD_STORAGE_KEY = 'activeBoardPath'; const MAX_OPEN_BOARDS = 6; +const UNIFIED_BOARD_PATH = '__unified__'; let boardTabsSortable = null; function normalizeBoardPath(dir) { @@ -8,6 +9,10 @@ function normalizeBoardPath(dir) { return ''; } + if (dir === UNIFIED_BOARD_PATH) { + return UNIFIED_BOARD_PATH; + } + const normalizedDir = dir.replace(/\\/g, '/').trim(); if (!normalizedDir) { return ''; @@ -38,6 +43,10 @@ async function authorizeBoardAccess(selection) { return ''; } + if (normalizedPath === UNIFIED_BOARD_PATH) { + return UNIFIED_BOARD_PATH; + } + let result = null; if ( selection && @@ -66,6 +75,9 @@ async function clearAuthorizedBoardAccess() { } function getBoardLabelFromPath(boardPath) { + if (boardPath === UNIFIED_BOARD_PATH) { + return 'All Boards'; + } const normalizedPath = normalizeBoardPath(boardPath).replace(/\/+$/, ''); const pathParts = normalizedPath.split('/').filter(Boolean); return pathParts[pathParts.length - 1] || 'Board'; @@ -239,7 +251,7 @@ async function closeBoardTab(boardPath) { return; } - if (!closedActiveBoard) { + if (!closedActiveBoard && activeBoard !== UNIFIED_BOARD_PATH) { renderBoardTabs(); return; } @@ -366,12 +378,18 @@ function renderBoardTabs() { tabsWrapper.classList.remove('hidden'); const activeBoard = normalizeBoardPath(window.boardRoot || getStoredActiveBoard()); - for (const boardPath of openBoards) { + const boardsToDisplay = [...openBoards]; + + for (const boardPath of boardsToDisplay) { const boardTab = document.createElement('div'); boardTab.classList.add('board-tab'); boardTab.setAttribute('data-board-path', boardPath); boardTab.setAttribute('role', 'presentation'); - boardTab.title = boardPath; + boardTab.title = boardPath === UNIFIED_BOARD_PATH ? 'View all open boards' : boardPath; + + if (boardPath === UNIFIED_BOARD_PATH) { + boardTab.classList.add('board-tab-unified'); + } const tabButton = document.createElement('button'); tabButton.type = 'button'; @@ -418,6 +436,15 @@ function renderBoardTabs() { await renderBoard(); }); + if (boardPath === UNIFIED_BOARD_PATH) { + const icon = document.createElement('span'); + icon.className = 'board-tab-unified-icon'; + icon.innerHTML = ''; + tabButton.prepend(icon); + } + + boardTab.appendChild(tabButton); + const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.classList.add('board-tab-close'); @@ -429,12 +456,53 @@ function renderBoardTabs() { event.stopPropagation(); await closeBoardTab(boardPath); }); - - boardTab.appendChild(tabButton); boardTab.appendChild(closeButton); tabsEl.appendChild(boardTab); } + // Global drag tracking for tabs + if (!window.__tabDragTrackerInitialized) { + window.__tabDragTrackerInitialized = true; + document.addEventListener('mousemove', (e) => { + if (document.body.classList.contains('board-card-drag-active')) { + const hit = document.elementFromPoint(e.clientX, e.clientY); + const boardTab = hit ? hit.closest('.board-tab[data-board-path]') : null; + + // Clear highlights from other tabs + document.querySelectorAll('.board-tab--drop-target').forEach(el => { + if (el !== boardTab) el.classList.remove('board-tab--drop-target'); + }); + + if (boardTab) { + const boardPath = boardTab.getAttribute('data-board-path'); + const UNIFIED_BOARD_PATH = '__unified__'; + + // Don't highlight if it's the "All Boards" tab or if the card already belongs to this board + if (boardPath === UNIFIED_BOARD_PATH) { + window.__activeBoardDropTarget = null; + return; + } + + if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { + const draggedItem = Sortable.active.dragged; + const cardPath = draggedItem ? draggedItem.getAttribute('data-path') : null; + if (cardPath && cardPath.startsWith(boardPath)) { + window.__activeBoardDropTarget = null; + return; + } + } + + window.__activeBoardDropTarget = boardPath; + boardTab.classList.add('board-tab--drop-target'); + } else { + window.__activeBoardDropTarget = null; + } + } else { + window.__activeBoardDropTarget = null; + } + }); + } + const addBoardTab = document.createElement('div'); addBoardTab.classList.add('board-tab', 'board-tab-add'); addBoardTab.setAttribute('role', 'presentation'); @@ -462,5 +530,44 @@ function renderBoardTabs() { addBoardTab.appendChild(addBoardButton); tabsEl.appendChild(addBoardTab); + const actualBoardsCount = openBoards.filter(b => b !== UNIFIED_BOARD_PATH).length; + if (actualBoardsCount > 1 && !openBoards.includes(UNIFIED_BOARD_PATH)) { + const addUnifiedTab = document.createElement('div'); + addUnifiedTab.classList.add('board-tab', 'board-tab-add', 'board-tab-unified-unopened'); + addUnifiedTab.setAttribute('role', 'presentation'); + + const addUnifiedButton = document.createElement('button'); + addUnifiedButton.type = 'button'; + addUnifiedButton.classList.add('board-tab-label', 'board-tab-add-label'); + addUnifiedButton.innerHTML = ' All Boards'; + addUnifiedButton.title = 'Open All Boards view'; + + if (openBoards.length >= MAX_OPEN_BOARDS) { + addUnifiedTab.classList.add('is-disabled'); + addUnifiedButton.disabled = true; + addUnifiedButton.title = `Maximum ${MAX_OPEN_BOARDS} open boards`; + } else { + addUnifiedButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + const updatedOpenBoards = [...openBoards, UNIFIED_BOARD_PATH]; + setStoredOpenBoards(updatedOpenBoards); + + window.boardRoot = UNIFIED_BOARD_PATH; + setStoredActiveBoard(UNIFIED_BOARD_PATH); + if (typeof resetBoardLabelFilter === 'function') resetBoardLabelFilter(); + if (typeof resetBoardSearch === 'function') resetBoardSearch(); + await renderBoard(); + }); + } + + addUnifiedTab.appendChild(addUnifiedButton); + tabsEl.appendChild(addUnifiedTab); + } + initializeBoardTabsSortable(tabsEl, openBoards.length > 1); + + if (typeof feather !== 'undefined' && feather && typeof feather.replace === 'function') { + feather.replace(); + } } diff --git a/app/board/boardViews.js b/app/board/boardViews.js index 63d1e72..d65b069 100644 --- a/app/board/boardViews.js +++ b/app/board/boardViews.js @@ -352,30 +352,34 @@ function createTemporalPlacementForDate(cardEntry, dueDateValue) { }; } -async function collectCardsForCalendar(boardRoot, lists) { - const listNames = Array.isArray(lists) ? lists : []; +async function collectCardsForCalendar(boardRoots, lists) { + const roots = Array.isArray(boardRoots) ? boardRoots : [boardRoots]; const cardPaths = []; - const listEntries = await Promise.all( - listNames.map(async (listName) => { - const listPath = `${boardRoot}${listName}`; - const cardNames = await window.board.listCards(listPath); - return { - listName, - listDisplayName: getBoardListDisplayName(listName), - listPath, - cardNames: Array.isArray(cardNames) ? cardNames : [], - }; - }), - ); + for (const boardRoot of roots) { + const listNames = Array.isArray(lists) ? lists : await window.board.listLists(boardRoot); + + const listEntries = await Promise.all( + listNames.map(async (listName) => { + const listPath = `${boardRoot}${listName}`; + const cardNames = await window.board.listCards(listPath); + return { + listName, + listDisplayName: getBoardListDisplayName(listName), + listPath, + cardNames: Array.isArray(cardNames) ? cardNames : [], + }; + }), + ); - for (const { listName, listDisplayName, listPath, cardNames } of listEntries) { - for (const cardName of cardNames) { - cardPaths.push({ - listName, - listDisplayName, - cardPath: `${listPath}/${cardName}`, - }); + for (const { listName, listDisplayName, listPath, cardNames } of listEntries) { + for (const cardName of cardNames) { + cardPaths.push({ + listName, + listDisplayName, + cardPath: `${listPath}/${cardName}`, + }); + } } } @@ -746,6 +750,28 @@ function createTemporalCardElement(cardEntry, isoDate, className) { const footer = document.createElement('span'); footer.className = 'board-temporal-card-footer'; + const UNIFIED_BOARD_PATH = '__unified__'; + if (window.boardRoot === UNIFIED_BOARD_PATH) { + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); + let boardPath = ''; + for (const root of openBoards) { + if (cardEntry.cardPath.startsWith(root)) { + boardPath = root; + break; + } + } + const boardName = typeof getBoardLabelFromPath === 'function' + ? getBoardLabelFromPath(boardPath) + : boardPath.split('/').filter(Boolean).pop(); + + const boardLabel = document.createElement('span'); + boardLabel.className = 'board-temporal-card-board-name'; + boardLabel.textContent = boardName; + boardLabel.title = `Board: ${boardName}`; + footer.appendChild(boardLabel); + } + const listNameText = String(cardEntry.listDisplayName || '').trim(); if (listNameText) { const listName = document.createElement('span'); diff --git a/app/board/openBoard.js b/app/board/openBoard.js index d8b1a44..8347583 100644 --- a/app/board/openBoard.js +++ b/app/board/openBoard.js @@ -70,6 +70,16 @@ async function pickAndOpenBoard() { return openBoard(authorizedBoardPath); } +async function isBoardRoot(path) { + try { + const directories = await window.board.listDirectories(path); + // A board root must contain at least one list directory like 000-Todo... + return directories.some(d => /^\d{3}-.+/.test(d) || d === 'XXX-Archive'); + } catch (e) { + return false; + } +} + async function openBoard( dir ) { const boardPath = await authorizeBoardAccess(dir); if (!boardPath) { @@ -88,6 +98,61 @@ async function openBoard( dir ) { await flushBoardLabelSettingsSave(); } + // Check if the selected folder is a board itself + const selectedIsBoard = await isBoardRoot(boardPath); + + if (!selectedIsBoard) { + // Scanner Mode: check subdirectories for boards + try { + const subdirs = await window.board.listDirectories(boardPath); + const foundBoardPaths = []; + + for (const subdir of subdirs) { + const fullSubPath = boardPath.endsWith('/') ? boardPath + subdir : boardPath + '/' + subdir; + if (await isBoardRoot(fullSubPath)) { + foundBoardPaths.push(fullSubPath); + } + } + + if (foundBoardPaths.length > 0) { + const confirmMsg = foundBoardPaths.length === 1 + ? `Found 1 board in this folder ("${foundBoardPaths[0].split('/').filter(Boolean).pop()}"). Open it?` + : `Found ${foundBoardPaths.length} boards in this folder. Would you like to open them all as tabs?`; + + if (window.confirm(confirmMsg)) { + let openedCount = 0; + const openBoards = getStoredOpenBoards(); + const availableSlots = Math.max(0, 6 - openBoards.length); // MAX_OPEN_BOARDS is 6 + + for (const p of foundBoardPaths) { + if (openedCount >= availableSlots) break; + const normalizedP = normalizeBoardPath(p); + if (!openBoards.includes(normalizedP)) { + ensureBoardInTabs(normalizedP); + openedCount++; + } + } + + if (foundBoardPaths.length > availableSlots) { + alert(`Only opened the first ${availableSlots} boards to stay within the 6-board limit.`); + } + + // Switch to the first newly opened board if we opened something + if (foundBoardPaths.length > 0) { + const firstOpened = normalizeBoardPath(foundBoardPaths[0]); + window.boardRoot = firstOpened; + setStoredActiveBoard(firstOpened); + await renderBoard(); + return true; + } + } + } + } catch (e) { + console.warn('Scanner failed', e); + } + } + + // Default behavior for a single board or a new board bootstrap const tabResult = ensureBoardInTabs(boardPath); if (tabResult && tabResult.limitReached) { if (typeof alertBoardTabLimit === 'function') { @@ -172,3 +237,4 @@ If you want, leave this card here as a little orientation guide. Or archive it i await renderBoard(); return true; } + diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 88a3768..ef42e9c 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -26,6 +26,7 @@ function getBoardRenderState() { } function destroyBoardSortables() { + cleanupSortableArtifacts(); const state = getBoardRenderState(); for (const sortable of state.activeSortables) { @@ -37,6 +38,38 @@ function destroyBoardSortables() { state.activeSortables = []; } +function cleanupSortableArtifacts() { + try { + // 1. Target standard Sortable.js classes + document.querySelectorAll('.sortable-drag, .sortable-ghost, .sortable-chosen, .sortable-fallback').forEach(el => { + if (el && el.parentNode) { + el.parentNode.removeChild(el); + } + }); + + // 2. Target ANY .card element that is not inside the main board + const board = document.getElementById('board'); + document.querySelectorAll('.card').forEach(card => { + if (board && !board.contains(card)) { + if (card.parentNode) { + card.parentNode.removeChild(card); + } + } + }); + + // 3. Final sweep of direct children of body that might be orphaned clones + Array.from(document.body.children).forEach(child => { + if (child.classList.contains('card') || (child.tagName === 'DIV' && child.querySelector('.card'))) { + document.body.removeChild(child); + } + }); + } catch (e) { + console.warn('Failed to perform global Exterminator cleanup', e); + } +} + + + function storeBoardSortables(sortables) { const state = getBoardRenderState(); state.activeSortables = Array.isArray(sortables) @@ -313,7 +346,12 @@ async function renderBoard() { const renderState = getBoardRenderState(); const requestId = renderState.requestId + 1; renderState.requestId = requestId; + + // Wipe all drag-and-drop phantoms before every single render + cleanupSortableArtifacts(); + const boardRoot = window.boardRoot; // set in the drop-zone handler + const UNIFIED_BOARD_PATH = '__unified__'; closeCardLabelPopover(); if (typeof closeListActionsPopover === 'function') { @@ -340,14 +378,38 @@ async function renderBoard() { try { const boardNameEl = document.getElementById('boardName'); - const [boardName, lists] = await Promise.all([ - window.board.getBoardName(boardRoot), - window.board.listLists(boardRoot), - ensureBoardLabelsLoaded(), - ]); + const isUnified = boardRoot === UNIFIED_BOARD_PATH; + + let openBoards = []; + if (isUnified) { + const stored = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + openBoards = stored.filter(b => b !== UNIFIED_BOARD_PATH); + } else { + openBoards = [boardRoot]; + } + + // For Unified view, we don't have a single board name + const boardName = isUnified ? 'All Boards' : await window.board.getBoardName(boardRoot); - if (!isCurrentBoardRenderRequest(requestId)) { - return; + const archiveBtn = document.getElementById('openArchiveBrowser'); + const settingsBtn = document.getElementById('openBoardSettings'); + const boardMenuBtn = document.getElementById('boardMenuButton'); + + if (archiveBtn) { + archiveBtn.disabled = isUnified; + archiveBtn.title = isUnified ? 'Archive browser is not available in Unified view' : 'Browse archived cards and lists'; + } + if (settingsBtn) { + settingsBtn.disabled = isUnified; + settingsBtn.title = isUnified ? 'Board settings are not available in Unified view' : 'Open board settings'; + } + if (boardMenuBtn) { + boardMenuBtn.title = isUnified ? 'Menu (All Boards)' : `Menu (${boardName})`; + } + + if (isUnified && openBoards.length === 0) { + renderBoardEmptyState(); + return; } const activeBoardView = typeof getActiveBoardView === 'function' @@ -356,7 +418,8 @@ async function renderBoard() { if (activeBoardView === 'calendar' && typeof renderCalendarBoard === 'function') { const stagingEl = document.createElement('div'); - const renderResult = await renderCalendarBoard(stagingEl, boardRoot, lists, { + // Pass array of roots to support unified calendar + const renderResult = await renderCalendarBoard(stagingEl, isUnified ? openBoards : boardRoot, isUnified ? null : await window.board.listLists(boardRoot), { deferSortableInit: true, }); @@ -383,7 +446,8 @@ async function renderBoard() { if (activeBoardView === 'this-week' && typeof renderThisWeekBoard === 'function') { const stagingEl = document.createElement('div'); - const renderResult = await renderThisWeekBoard(stagingEl, boardRoot, lists, { + // Pass array of roots to support unified this-week + const renderResult = await renderThisWeekBoard(stagingEl, isUnified ? openBoards : boardRoot, isUnified ? null : await window.board.listLists(boardRoot), { deferSortableInit: true, }); @@ -408,17 +472,53 @@ async function renderBoard() { return; } - const listsWithCards = await Promise.all( - lists.map(async (listName) => { - const listPath = boardRoot + listName; - const cards = await window.board.listCards(listPath); - return { listName, listPath, cards }; - }) - ); + let listsWithCards = []; + if (isUnified) { + const aggregatedLists = new Map(); // displayName -> { listName, listPath (first one), cardPaths[] } + for (const root of openBoards) { + const boardLists = await window.board.listLists(root); + for (const listName of boardLists) { + const displayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(listName) : listName; + if (!aggregatedLists.has(displayName)) { + aggregatedLists.set(displayName, { listName, listPath: root + listName, cardPaths: [] }); + } + const entry = aggregatedLists.get(displayName); + const listPath = root + listName; + const cardNames = await window.board.listCards(listPath); + for (const cardName of cardNames) { + entry.cardPaths.push(listPath + '/' + cardName); + } + } + } + listsWithCards = Array.from(aggregatedLists.values()).map(entry => ({ + listName: entry.listName, + listPath: entry.listPath, + cardPaths: entry.cardPaths, + isUnified: true, + displayName: entry.displayName + })); + } else { + const lists = await window.board.listLists(boardRoot); + await ensureBoardLabelsLoaded(); + listsWithCards = await Promise.all( + lists.map(async (listName) => { + const listPath = boardRoot + listName; + const cards = await window.board.listCards(listPath); + return { + listName, + listPath, + cardPaths: cards.map(c => listPath + '/' + c), + displayName: typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(listName) : listName + }; + }) + ); + } const listBuilds = await Promise.all( - listsWithCards.map(({ listName, listPath, cards }) => createListElement(listName, listPath, cards, { + listsWithCards.map(({ listName, listPath, cardPaths, isUnified, displayName }) => createListElement(listName, listPath, cardPaths, { deferSortableInit: true, + isUnified, + displayName })) ); @@ -460,31 +560,33 @@ async function renderBoard() { } } - activeSortables.push(new Sortable(boardEl, { - group: 'lists', - animation: 150, - onEnd: async (evt) => { - const finalOrder = [...evt.to.querySelectorAll('.list')].map((list) => - list.getAttribute('data-path') - ); + if (!isUnified) { + activeSortables.push(new Sortable(boardEl, { + group: 'lists', + animation: 150, + onEnd: async (evt) => { + const finalOrder = [...evt.to.querySelectorAll('.list')].map((list) => + list.getAttribute('data-path') + ); - let directoryCounter = 0; - for (const directoryPath of finalOrder) { - const directoryNumber = (directoryCounter).toLocaleString('en-US', { - minimumIntegerDigits: 3, - useGrouping: false - }); + let directoryCounter = 0; + for (const directoryPath of finalOrder) { + const directoryNumber = (directoryCounter).toLocaleString('en-US', { + minimumIntegerDigits: 3, + useGrouping: false + }); - const newDirectoryName = window.boardRoot + directoryNumber + await window.board.getListDirectoryName(directoryPath).slice(3); + const newDirectoryName = window.boardRoot + directoryNumber + await window.board.getListDirectoryName(directoryPath).slice(3); - await window.board.moveCard(directoryPath, newDirectoryName); + await window.board.moveCard(directoryPath, newDirectoryName); - directoryCounter++; - } + directoryCounter++; + } - await renderBoard(); - } - })); + await renderBoard(); + } + })); + } } storeBoardSortables(activeSortables); diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 4713452..7cb2726 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -69,6 +69,29 @@ async function createCardElement(cardPath) { cardLabelsWrap.className = 'card-labels'; metadata.appendChild(cardLabelsWrap); + const UNIFIED_BOARD_PATH = '__unified__'; + if (window.boardRoot === UNIFIED_BOARD_PATH) { + const boardIndicator = document.createElement('div'); + boardIndicator.className = 'card-board-indicator'; + + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); + let boardPath = ''; + for (const root of openBoards) { + if (cardPath.startsWith(root)) { + boardPath = root; + break; + } + } + + const boardName = typeof getBoardLabelFromPath === 'function' + ? getBoardLabelFromPath(boardPath) + : boardPath.split('/').filter(Boolean).pop(); + + boardIndicator.textContent = boardName; + cardFrame.prepend(boardIndicator); + } + async function renderDueDateDisplay() { if (!dueDateValue) { formattedDue.textContent = ''; diff --git a/app/init.js b/app/init.js index c70f3f9..c17a935 100644 --- a/app/init.js +++ b/app/init.js @@ -39,7 +39,8 @@ function formatLocalIsoDate(dateValue = new Date()) { } function getDueNotificationBoardRoots() { - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== '__unified__'); if (Array.isArray(openBoards) && openBoards.length > 0) { return openBoards; } @@ -708,8 +709,10 @@ async function init() { initializeTooltips(); if (window.board && typeof window.board.adoptLegacyBoardRoots === 'function' && typeof getStoredOpenBoards === 'function') { + const storedBoards = getStoredOpenBoards(); + const openBoards = storedBoards.filter(b => b !== '__unified__'); try { - await window.board.adoptLegacyBoardRoots(getStoredOpenBoards()); + await window.board.adoptLegacyBoardRoots(openBoards); } catch (error) { console.warn('Unable to migrate previously opened boards into trusted board access.', error); } diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index ac0c89f..1895456 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -1,61 +1,83 @@ -async function createListElement(name, listPath, cardNames, options = {}) { +async function createListElement(name, listPath, cardPaths, options = {}) { + if (!window._cardMoveLock) window._cardMoveLock = false; + + const isUnified = options.isUnified === true; + const listDisplayName = options.displayName || name; const listEl = document.createElement('div'); listEl.className = 'list'; listEl.dataset.path = listPath; + listEl.dataset.displayName = listDisplayName; + if (isUnified) { + listEl.dataset.isUnified = 'true'; + } const header = document.createElement('div'); header.className = 'list-header'; const listName = document.createElement('span'); - listName.setAttribute('contenteditable',true); - listName.setAttribute('data-listpath',listPath); - listName.textContent = name.substring(4,name.length-6); + + if (!isUnified) { + listName.setAttribute('contenteditable',true); + listName.setAttribute('data-listpath',listPath); + + listName.addEventListener('keydown', async function (e){ + if ( e.code == 'Enter' ) { + e.preventDefault(); + return; } + }); - listName.addEventListener('keydown', async function (e){ + listName.addEventListener('keyup', async (e) => { if ( e.code == 'Enter' ) { e.preventDefault(); - - - return; } - }); - - listName.addEventListener('keyup', async (e) => { - if ( e.code == 'Enter' ) { - e.preventDefault(); - - await renameList(e); - - return; - } - }); - - listName.addEventListener('focusout', async (e) => { await renameList(e) }); - - const actionsBtn = document.createElement('button'); - actionsBtn.type = 'button'; - actionsBtn.className = 'list-actions-button'; - actionsBtn.title = 'List actions'; - actionsBtn.setAttribute('aria-label', 'List actions'); - actionsBtn.innerHTML = ''; - actionsBtn.addEventListener('click', async function (e) { - e.stopPropagation(); - toggleListActionsPopover({ - anchorElement: actionsBtn, - listPath, - listDisplayName: listName.textContent, - cardCount: cardNames.length, + await renameList(e); + return; + } }); - }); + + listName.addEventListener('focusout', async (e) => { await renameList(e) }); + } else { + listEl.classList.add('list--unified'); + } + + // Handle names like "001-Todo-suffix" or just "Todo" + const displayName = typeof getBoardListDisplayName === 'function' + ? getBoardListDisplayName(name) + : (name.length > 10 ? name.substring(4, name.length - 6) : name); + + listName.textContent = displayName; header.appendChild(listName); - header.appendChild(actionsBtn); + + if (!isUnified) { + const actionsBtn = document.createElement('button'); + actionsBtn.type = 'button'; + actionsBtn.className = 'list-actions-button'; + actionsBtn.title = 'List actions'; + actionsBtn.setAttribute('aria-label', 'List actions'); + actionsBtn.innerHTML = ''; + actionsBtn.addEventListener('click', async function (e) { + e.stopPropagation(); + toggleListActionsPopover({ + anchorElement: actionsBtn, + listPath, + listDisplayName: listName.textContent, + cardCount: cardPaths.length, + }); + }); + header.appendChild(actionsBtn); + } + listEl.appendChild(header); const cardsEl = document.createElement('div'); cardsEl.className = 'cards'; cardsEl.dataset.path = listPath; + cardsEl.dataset.displayName = listDisplayName; + if (isUnified) { + cardsEl.dataset.isUnified = 'true'; + } listEl.appendChild(cardsEl); const cardElements = await Promise.all( - cardNames.map((cardName) => createCardElement(listPath + '/' + cardName)) + cardPaths.map((cardPath) => createCardElement(cardPath)) ); for (const cardEl of cardElements) { @@ -72,63 +94,288 @@ async function createListElement(name, listPath, cardNames, options = {}) { animation: 150, draggable: '.card', disabled: isBoardLabelFilterActive(), - onEnd: async (evt) => { + onEnd: (evt) => { + if (window._cardMoveLock) return; + window._cardMoveLock = true; + + // 1. Capture all necessary DOM data synchronously const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; - const targetListPath = evt && evt.to ? evt.to.dataset.path : ''; - - const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => - card.getAttribute('data-path') // array of CURRENT filenames in final order - ); - - const allCardsInList = await window.board.listCards(evt.to.dataset.path); - - let tempFileCounter = 0; - for (const fileName of allCardsInList) { - await window.board.moveCard(evt.to.dataset.path + '/' + fileName, evt.to.dataset.path + '/' + fileName.replace('.md','.tmp')); + const targetIsUnified = evt && evt.to && evt.to.dataset ? evt.to.dataset.isUnified === 'true' : false; + const oldIndex = evt.oldIndex; + const newIndex = evt.newIndex; + const fromEl = evt.from; + const toEl = evt.to; + + // 0. Early Exit if no actual movement occurred + if (fromEl === toEl && oldIndex === newIndex) { + window._cardMoveLock = false; + return; } - let fileCounter = 0; - let movedCardNextPath = ''; - for (const filePath of finalOrder) { - - let fileNumber = (fileCounter).toLocaleString('en-US', { - minimumIntegerDigits: 3, - useGrouping: false - }); - - let adjustedFrom; - - if ( !filePath.includes( evt.to.dataset.path ) ) { - adjustedFrom = filePath; - } else { - adjustedFrom = filePath.replace('.md','.tmp'); + // Watchdog timer to prevent permanent locks if IPC hangs + let watchdogTimer = setTimeout(() => { + if (window._cardMoveLock) { + console.error('WATCHDOG: Card move operation timed out. Forcing UI refresh.'); + try { + document.querySelectorAll('.sortable-drag, .sortable-ghost, .sortable-chosen').forEach(el => el.remove()); + if (typeof renderBoard === 'function') renderBoard().finally(() => { window._cardMoveLock = false; }); + } catch (e) { window._cardMoveLock = false; } + } + }, 3000); + + // Decouple logic from Sortable's event loop + setTimeout(async () => { + try { + let targetDisplayName = toEl && toEl.dataset ? toEl.dataset.displayName : ''; + + const parseDisplayName = (raw) => { + if (typeof getBoardListDisplayName === 'function') return getBoardListDisplayName(raw); + const m = String(raw || '').match(/^\d{3}-(.*?)(-[^-]{5}|-stock)$/); + if (m) return m[1].trim(); + if (String(raw).length > 10) { + // Fallback for 001-Doing-stock or 001-Doing-abcde + const parts = String(raw).split('-'); + if (parts.length >= 3 && parts[0].length === 3) return parts.slice(1, -1).join('-'); + return String(raw).substring(4, String(raw).length - 6); + } + return String(raw); + }; + + if (!targetDisplayName && toEl && toEl.dataset && toEl.dataset.path) { + const dirName = toEl.dataset.path.split('/').filter(Boolean).pop(); + targetDisplayName = dirName; + } + targetDisplayName = parseDisplayName(targetDisplayName); + + // 1. Manual Hit-Test for Board Tabs (Drag-to-Tab) + // originalEvent might be a MouseEvent or TouchEvent + const originalEvent = evt.originalEvent; + let clientX, clientY; + + if (originalEvent) { + clientX = originalEvent.clientX !== undefined ? originalEvent.clientX : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientX : 0); + clientY = originalEvent.clientY !== undefined ? originalEvent.clientY : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientY : 0); } - let adjustedTo = evt.to.dataset.path + '/' + fileNumber + await window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); + let targetBoardPath = null; + if (clientX !== undefined && clientY !== undefined) { + const tabs = document.querySelectorAll('.board-tab[data-board-path]'); + for (const tab of tabs) { + const rect = tab.getBoundingClientRect(); + if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) { + targetBoardPath = tab.getAttribute('data-board-path'); + break; + } + } + } - await window.board.moveCard(adjustedFrom, adjustedTo); - if (movedCardOriginalPath && filePath === movedCardOriginalPath) { - movedCardNextPath = adjustedTo; + if (targetBoardPath) { + const UNIFIED_BOARD_PATH = '__unified__'; + + if (targetBoardPath !== UNIFIED_BOARD_PATH) { + // If dropped on own board tab, just switch to it and return + if (movedCardOriginalPath && movedCardOriginalPath.startsWith(targetBoardPath)) { + window.boardRoot = targetBoardPath; + if (typeof setStoredActiveBoard === 'function') setStoredActiveBoard(targetBoardPath); + await renderBoard(); + return; + } + + // Move card to different board + try { + const targetLists = await window.board.listLists(targetBoardPath); + let targetListName = ''; + const sourceListDisplayName = fromEl && fromEl.dataset ? fromEl.dataset.displayName : ''; + + for (const listName of targetLists) { + const displayName = typeof getBoardListDisplayName === 'function' + ? getBoardListDisplayName(listName) + : (listName.length > 10 ? listName.substring(4, listName.length - 6) : listName); + if (displayName === sourceListDisplayName) { + targetListName = listName; + break; + } + } + + if (!targetListName) { + const nonArchive = targetLists.filter(l => typeof l === 'string' && !l.includes('-Archive')); + if (nonArchive.length > 0) targetListName = nonArchive[0]; + } + + if (targetListName) { + const finalTargetListPath = targetBoardPath + targetListName; + const cardFileName = window.board.getCardFileName(movedCardOriginalPath); + const nextPrefix = await window.board.listCards(finalTargetListPath).then(cards => { + const prefixes = cards.map(c => parseInt(c.slice(0, 3))).filter(n => !isNaN(n)); + const max = prefixes.length > 0 ? Math.max(...prefixes) : -1; + return String(max + 1).padStart(3, '0'); + }); + const destinationPath = finalTargetListPath + '/' + nextPrefix + cardFileName.slice(3); + + // Switch to the target board state first + window.boardRoot = targetBoardPath; + if (typeof setStoredActiveBoard === 'function') { + setStoredActiveBoard(targetBoardPath); + } + + await window.board.moveCard(movedCardOriginalPath, destinationPath); + await renderBoard(); + return; + } + } catch (e) { + console.error('Failed to move card to another board', e); + await renderBoard(); + return; + } + } } - fileCounter++; + // 2. Resolve target list path (preserving board if in Unified view) + if (!toEl) { + await renderBoard(); + return; + } - } + let targetListPath = toEl.dataset.path; + + if (targetIsUnified && movedCardOriginalPath) { + const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const actualBoards = openBoards.filter(b => b !== '__unified__').sort((a,b) => b.length - a.length); + let cardBoardRoot = actualBoards.find(root => movedCardOriginalPath.startsWith(root)); + + if (cardBoardRoot) { + try { + const boardLists = await window.board.listLists(cardBoardRoot); + let foundList = null; + for (const boardListName of boardLists) { + const boardListDisplayName = parseDisplayName(boardListName); + if (String(boardListDisplayName).trim().toLowerCase() === String(targetDisplayName).trim().toLowerCase()) { + foundList = boardListName; + break; + } + } + + if (foundList) { + targetListPath = cardBoardRoot + foundList; + } else { + + // Create list on this board if missing + const currentLists = await window.board.listLists(cardBoardRoot); + const prefix = String(currentLists.length).padStart(3, '0'); + const suffix = typeof rand5 === 'function' ? await rand5() : 'stock'; + const newListName = `${prefix}-${targetDisplayName}-${suffix}`; + const newListPath = cardBoardRoot + newListName; + await window.board.createList(newListPath); + targetListPath = newListPath; + } + } catch (e) { + console.error('Failed to resolve target list in Unified view', e); + await renderBoard(); + return; + } + } + } - if ( - movedCardNextPath && - sourceListPath && - targetListPath && - sourceListPath !== targetListPath && - window.board && - typeof window.board.recordCardListMove === 'function' - ) { - await window.board.recordCardListMove(movedCardNextPath, sourceListPath, targetListPath); - } + if (!targetListPath) { + await renderBoard(); + return; + } - await renderBoard(); - + try { + const finalOrder = [...toEl.querySelectorAll('.card')].map(card => + card.getAttribute('data-path') + ); + + // 3. Robust reordering + const resolvedPathBase = targetListPath.replace(/\/$/, ''); + const allCardsInTargetPhysicalList = await window.board.listCards(targetListPath); + + // Rename all existing files in target physical list to .tmp to avoid collisions during re-indexing + for (const fileName of allCardsInTargetPhysicalList) { + const fullPath = targetListPath.endsWith('/') ? targetListPath + fileName : targetListPath + '/' + fileName; + await window.board.moveCard(fullPath, fullPath.replace('.md', '.tmp')); + } + + let fileCounter = 0; + let movedCardNextPath = ''; + + for (const filePath of finalOrder) { + if (!filePath) continue; + + const isTheMovedCard = (filePath === movedCardOriginalPath); + const belongsToThisBoard = filePath.startsWith(resolvedPathBase); + + // If in unified view, we only reorder cards that belong to this physical list's board + // OR the card we just moved into this column from another list on the same board + if (!belongsToThisBoard && !isTheMovedCard) { + continue; + } + + let fileNumber = String(fileCounter).padStart(3, '0'); + let adjustedFrom = filePath; + + // If it was already in this list, it now has a .tmp extension + const allCardsSet = new Set(allCardsInTargetPhysicalList); + const fileName = window.board.getCardFileName(filePath); + if (allCardsSet.has(fileName)) { + adjustedFrom = filePath.replace('.md', '.tmp'); + } + + const cardFileName = window.board.getCardFileName(filePath); + const fileNameSuffix = cardFileName.slice(3); // Fix: don't add extra hyphen, preserve existing suffix + const adjustedTo = (targetListPath.endsWith('/') ? targetListPath : targetListPath + '/') + fileNumber + fileNameSuffix.replace('.tmp', '.md'); + + await window.board.moveCard(adjustedFrom, adjustedTo); + + if (isTheMovedCard) { + movedCardNextPath = adjustedTo; + } + + fileCounter++; + } + + if ( + movedCardNextPath && + sourceListPath && + targetListPath && + sourceListPath !== targetListPath && + window.board && + typeof window.board.recordCardListMove === 'function' && + movedCardNextPath.startsWith(targetListPath.replace(/\/$/, '')) // Only record if same board + ) { + try { + await window.board.recordCardListMove(movedCardNextPath, sourceListPath, targetListPath); + } catch (recErr) { + console.warn('Failed to record card move activity', recErr); + } + } + } catch (e) { + console.error('Critical error during card movement/reordering', e); + } finally { + clearTimeout(watchdogTimer); + // Aggressive Cleanup: Forcefully remove any lingering Sortable.js artifacts + // that might be stuck in the DOM (even outside the main board container). + try { + document.querySelectorAll('.sortable-drag, .sortable-ghost, .sortable-chosen').forEach(el => el.remove()); + } catch (cleanupErr) {} + + // Tiny delay to let Sortable finish its internal UI cleanup/animations + // before we completely replace the board DOM. + setTimeout(async () => { + try { + await renderBoard(); + } catch (renderErr) {} + window._cardMoveLock = false; + }, 100); + } + } catch (outerErr) { + console.error('Outer error in decoupled onEnd', outerErr); + clearTimeout(watchdogTimer); + window._cardMoveLock = false; + await renderBoard(); + } + }, 0); } })); }; @@ -145,7 +392,7 @@ async function createListElement(name, listPath, cardNames, options = {}) { } async function renameList( e ) { - const currentListName = await window.board.getListDirectoryName( e.target.dataset.listpath ); + const currentListName = window.board.getListDirectoryName( e.target.dataset.listpath ); const listNameMatch = currentListName.match(/^(\d{3}-)(.*?)(-[^-]{5}|-stock)$/); if (!listNameMatch) { diff --git a/app/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index e4dfc59..6871a1d 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -1031,12 +1031,27 @@ function getCardEditorListDisplayName(directoryName) { return normalized || 'Untitled'; } -async function getOrderedListPaths() { - if (!window.boardRoot) { +async function getOrderedListPaths(cardPath) { + let boardRoot = window.boardRoot; + const UNIFIED_BOARD_PATH = '__unified__'; + + if (boardRoot === UNIFIED_BOARD_PATH && cardPath) { + // Derive board root from card path + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); + for (const root of openBoards) { + if (cardPath.startsWith(root)) { + boardRoot = root; + break; + } + } + } + + if (!boardRoot || boardRoot === UNIFIED_BOARD_PATH) { return []; } - const listNames = await window.board.listLists(window.boardRoot); - return listNames.map((listName) => window.boardRoot + listName); + const listNames = await window.board.listLists(boardRoot); + return listNames.map((listName) => boardRoot + listName); } async function updateCardEditorListDropdown(cardPath) { @@ -1045,7 +1060,7 @@ async function updateCardEditorListDropdown(cardPath) { return; } - const listPaths = await getOrderedListPaths(); + const listPaths = await getOrderedListPaths(cardPath); const currentListPath = getCardListPath(cardPath); listSelect.innerHTML = ''; @@ -1077,7 +1092,7 @@ async function updateCardEditorListDropdown(cardPath) { } async function resolveCardMoveTarget(cardPath) { - const listPaths = await getOrderedListPaths(); + const listPaths = await getOrderedListPaths(cardPath); const currentListPath = getCardListPath(cardPath); const currentIndex = listPaths.indexOf(currentListPath); diff --git a/app/utilities/cardDragTilt.js b/app/utilities/cardDragTilt.js index 221532a..0398fbb 100644 --- a/app/utilities/cardDragTilt.js +++ b/app/utilities/cardDragTilt.js @@ -32,6 +32,11 @@ function unlockBoardCardTextSelection() { } document.body.classList.remove('board-card-drag-active'); + const dropTargets = document.querySelectorAll('.board-tab--drop-target'); + for (const target of dropTargets) { + target.classList.remove('board-tab--drop-target'); + } + window.__activeBoardDropTarget = null; } function isBoardCardDragTiltElement(element) { @@ -242,13 +247,28 @@ function createBoardCardSortableOptions(options = {}) { beginBoardCardDragTilt(evt); }, onEnd(evt) { - try { - if (typeof baseOnEnd === 'function') { - return baseOnEnd.call(this, evt); - } - } finally { + const runCleanup = () => { endBoardCardDragTilt(evt); unlockBoardCardTextSelection(); + }; + + if (typeof baseOnEnd !== 'function') { + runCleanup(); + return; + } + + const result = baseOnEnd.call(this, evt); + if (result && typeof result.then === 'function') { + return result.then( + () => runCleanup(), + (err) => { + runCleanup(); + throw err; + } + ); + } else { + runCleanup(); + return result; } }, }; diff --git a/main.js b/main.js index 3a7ac03..d4651cf 100644 --- a/main.js +++ b/main.js @@ -599,6 +599,14 @@ function requireWritablePath(sender, candidatePath) { return normalizedPath; } + // Also allow writing to any trusted root + const trustedRoots = readTrustedBoardRoots(); + for (const trustedRoot of trustedRoots) { + if (isPathInsideRoot(trustedRoot, normalizedPath)) { + return normalizedPath; + } + } + throw new Error('UNAUTHORIZED_PATH'); } @@ -607,6 +615,11 @@ function requireActiveBoardRootForSender(sender) { const activeBoardRoot = typeof senderState.activeBoardRoot === 'string' ? senderState.activeBoardRoot : ''; + + if (activeBoardRoot === '__unified__') { + return '__unified__'; + } + if (!activeBoardRoot) { throw new Error('UNAUTHORIZED_BOARD_ROOT'); } @@ -615,9 +628,9 @@ function requireActiveBoardRootForSender(sender) { } function authorizeTrustedBoardRootForSender(sender, boardRoot) { - const normalizedBoardRoot = normalizeBoardRootPath(boardRoot); + const normalizedBoardRoot = boardRoot === '__unified__' ? '__unified__' : normalizeBoardRootPath(boardRoot); const trustedRoots = readTrustedBoardRoots(); - if (!normalizedBoardRoot || !trustedRoots.has(normalizedBoardRoot)) { + if (normalizedBoardRoot !== '__unified__' && (!normalizedBoardRoot || !trustedRoots.has(normalizedBoardRoot))) { return { ok: false, error: 'UNTRUSTED_BOARD_ROOT' }; } @@ -2054,10 +2067,29 @@ ipcMain.handle('board-call', async (event, payload = {}) => { } case 'recordCardListMove': { - const boardRoot = requireActiveBoardRootForSender(event.sender); + let boardRoot = requireActiveBoardRootForSender(event.sender); + if (boardRoot === '__unified__') { + const cardPath = normalizeAbsolutePath(args[0]); + if (cardPath) { + const trustedRoots = readTrustedBoardRoots(); + for (const root of trustedRoots) { + if (isPathInsideRoot(root, cardPath)) { + boardRoot = root; + break; + } + } + } + } + const cardPath = requireWritablePath(event.sender, args[0]); const fromListPath = requireWritablePath(event.sender, args[1]); const toListPath = requireWritablePath(event.sender, args[2]); + + // If after derivation we still don't have a physical board root, we can't record. + if (boardRoot === '__unified__') { + return { ok: false, error: 'UNABLE_TO_DETERMINE_BOARD_ROOT' }; + } + return recordCardListMove(boardRoot, cardPath, fromListPath, toListPath); } @@ -2073,11 +2105,32 @@ ipcMain.handle('board-call', async (event, payload = {}) => { const activeBoardRoot = senderState.activeBoardRoot; const movingBoardRoot = sourcePath === activeBoardRoot; - if (!movingBoardRoot && !isPathInsideRoot(activeBoardRoot, destinationPath)) { - throw new Error('UNAUTHORIZED_PATH'); + if (!movingBoardRoot) { + let authorized = activeBoardRoot && isPathInsideRoot(activeBoardRoot, destinationPath); + if (!authorized) { + const trustedRoots = readTrustedBoardRoots(); + for (const trustedRoot of trustedRoots) { + if (isPathInsideRoot(trustedRoot, destinationPath)) { + authorized = true; + break; + } + } + } + if (!authorized) { + throw new Error('UNAUTHORIZED_PATH'); + } } - await fsPromises.rename(sourcePath, destinationPath); + try { + await fsPromises.rename(sourcePath, destinationPath); + } catch (err) { + if (err.code === 'EXDEV') { + await fsPromises.copyFile(sourcePath, destinationPath); + await fsPromises.unlink(sourcePath); + } else { + throw err; + } + } if (movingBoardRoot) { replaceTrustedBoardRoot(sourcePath, destinationPath); diff --git a/readme.md b/readme.md index 13a90db..d1f1575 100644 --- a/readme.md +++ b/readme.md @@ -23,6 +23,27 @@ Signboard is free for personal use. If you are using Signboard for your work it - ⌨️ Keyboard shortcuts - 🤖 MCP server - 💻 CLI +- 🔗 Unified Board View (New!) + +--- + +## 🔗 Unified Board View (Experimental) + +Signboard now supports a **Unified View** that aggregates all your open boards into a single workspace. + +### Key Features +- **Aggregated Kanban**: Columns with the same name (e.g., "Todo", "Done") from different boards are merged into a single view. +- **Unified Calendar & Week Views**: See all your tasks from every board in one temporal view. +- **Board Indicators**: Each card in the Unified view shows which board it belongs to. +- **Cross-Board Drag and Drop**: + - Drag a card between columns in Unified view and it stays on its original board. + - Drag a card directly to a **Board Tab** at the top to move it to that board. +- **Auto-List Creation**: Dragging a card to a column that doesn't exist on its original board will automatically create that list for you. + +### How to use +1. Open two or more boards using the "+ Add Board" button or by dragging folders into the app. +2. Click the **"All Boards"** button that appears next to the Add Board button. +3. Switch between individual boards and the All Boards view using the tabs. You can close the All Boards tab at any time. --- @@ -287,3 +308,27 @@ Signboard includes static versions of the following open source libraries: - [SortableJS](https://github.com/SortableJS/Sortable) – [MIT License](https://github.com/SortableJS/Sortable/blob/master/LICENSE) - [Feather Icons](https://github.com/feathericons/feather) – [MIT License](https://github.com/feathericons/feather/blob/master/LICENSE) - [fDatepicker](https://github.com/liedekef/fdatepicker) – [MIT License](https://github.com/liedekef/fdatepicker/blob/master/LICENSE.md) + +--- + +## 🛠 Technical Notes for Handoff (Unified View & Drag-and-Drop) + +This section documents the architectural changes made during the implementation of the **Unified View** and the hardened **Drag-and-Drop** system. + +### Unified View Architecture +- **Virtual Root**: Uses `__unified__` as a special `window.boardRoot`. +- **Aggregation**: `renderBoard.js` and `boardViews.js` were updated to aggregate lists and cards from all `getStoredOpenBoards()` when in unified mode. +- **Label Merging**: `boardLabels.js` merges labels from all open boards into a single map so filtering and colors work correctly across boards. +- **Permission Relaxation**: `main.js` now allows write access to any "trusted" board root (not just the active one) to enable editing cards in Unified view. + +### Hardened Drag-and-Drop +- **Manual Hit-Testing**: Due to Sortable's `forceFallback: true`, standard `drop` events on tabs are unreliable. The `onEnd` handler in `createListElement.js` now uses manual coordinate mapping via `getBoundingClientRect` to detect drops on board tabs. +- **Board Preservation**: In Unified view, reordering logic specifically filters cards by their physical board path. If a card is moved to a column that doesn't exist on its original board, the system auto-creates the directory. +- **Cross-Device Moves**: `main.js` now handles `EXDEV` errors during `fs.rename` by falling back to `copyFile` + `unlink`. +- **Async Synchronization**: `app/utilities/cardDragTilt.js` was updated to correctly `await` the `onEnd` promise, preventing premature cleanup of the drag state (`__activeBoardDropTarget`). +- **Filename Integrity**: Fixed a bug where re-indexing was injecting double hyphens into card filenames. + +### Future Work +- **Archive Browser**: Currently disabled in Unified view. Could be enabled by aggregating archive entries from all boards. +- **Board Settings**: Currently disabled in Unified view. +- **Cross-Board Reordering**: Reordering currently only affects cards from the same board within a merged column. diff --git a/static/styles.css b/static/styles.css index 330121a..bbb7a43 100644 --- a/static/styles.css +++ b/static/styles.css @@ -466,6 +466,18 @@ body:not(.board-empty) #boardTabs { padding-right: 12px; } +.board-tab-unified-unopened { + opacity: 0.5; + filter: grayscale(0.5); + transition: opacity .15s ease, filter .15s ease; +} + +.board-tab-unified-unopened:hover { + opacity: 1; + filter: grayscale(0); +} + + .board-tab-add .board-tab-label { padding-right: 0; } @@ -3450,3 +3462,89 @@ select:disabled { .list { box-shadow: none; border-color: #bbb; } .card { box-shadow: none; border-color: #bbb; } } + +/* 12) Unified View Styles */ +.board-tab-unified { + border-color: color-mix(in oklab, var(--accent) 35%, var(--border)); + background: color-mix(in oklab, var(--accent) 8%, var(--bg-card)); +} + +.board-tab-unified .board-tab-unified-icon { + display: inline-flex; + align-items: center; + margin-right: 6px; + color: var(--accent); +} + +.board-tab-unified .board-tab-unified-icon svg { + width: 14px; + height: 14px; +} + +.board-tab.board-tab-unified.is-active { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--bg-card) 85%, var(--accent) 15%) 0%, + var(--bg) 100% + ); + border-color: color-mix(in oklab, var(--accent) 45%, var(--border)); +} + +.board-tab--drop-target { + border-style: dashed !important; + border-color: var(--accent) !important; + background: color-mix(in oklab, var(--accent) 15%, var(--bg-card)) !important; +} + +.card-sortable--fallback, +.sortable-fallback, +.sortable-drag, +.sortable-ghost { + pointer-events: none !important; +} + +/* Ensure tab sub-elements don't interfere with drag-to-tab detection */ +.board-card-drag-active .board-tab-label, +.board-card-drag-active .board-tab-close, +.board-card-drag-active .board-tab-unified-icon, +.board-card-drag-active .board-tab-unified-icon svg { + pointer-events: none !important; +} + +.list--unified { + border-style: dashed; + border-color: color-mix(in oklab, var(--accent) 30%, var(--border)); +} + +.list--unified .list-header { + border-bottom-style: dashed; + color: var(--accent); +} + +.card-board-indicator { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent); + margin-bottom: 4px; + opacity: 0.8; +} + +.board-temporal-card-board-name { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent); + background: color-mix(in oklab, var(--accent) 10%, var(--bg-card)); + color: var(--accent); + font-size: 10px; + line-height: 1.15; + letter-spacing: .01em; + padding: 2px 6px; +}