From 3f444a83d084616758bc9188fa119e44372b90a9 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 12:29:38 +0200 Subject: [PATCH 01/21] feat: implement unified view for all open boards - Add 'All Boards' tab when 2+ boards are open - Aggregate cards and lists from all open boards in Kanban, Calendar, and Week views - Merge labels across boards for consistent filtering - Relax permissions to allow cross-board card moves - Styling for unified view elements - Disable board-specific settings/archive in unified view --- app/board/boardLabels.js | 30 ++++++- app/board/boardTabs.js | 64 +++++++++++---- app/board/boardViews.js | 46 ++++++----- app/board/renderBoard.js | 129 +++++++++++++++++++++--------- app/lists/createListElement.js | 87 +++++++++++--------- app/modals/toggleEditCardModal.js | 26 ++++-- main.js | 26 +++++- static/styles.css | 37 +++++++++ 8 files changed, 324 insertions(+), 121 deletions(-) diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index f936499..7cb53ed 100644 --- a/app/board/boardLabels.js +++ b/app/board/boardLabels.js @@ -1886,10 +1886,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 +2323,7 @@ function resetBoardLabelFilter() { } async function ensureBoardLabelsLoaded() { + const UNIFIED_BOARD_PATH = '__unified__'; if (!window.boardRoot) { const state = getBoardLabelState(); state.importSummaryBoardRoot = ''; @@ -2339,6 +2341,32 @@ async function ensureBoardLabelsLoaded() { return; } + if (window.boardRoot === UNIFIED_BOARD_PATH) { + const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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..3454e84 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'; @@ -366,12 +378,21 @@ function renderBoardTabs() { tabsWrapper.classList.remove('hidden'); const activeBoard = normalizeBoardPath(window.boardRoot || getStoredActiveBoard()); - for (const boardPath of openBoards) { + const boardsToDisplay = [...openBoards]; + if (openBoards.length > 1) { + boardsToDisplay.unshift(UNIFIED_BOARD_PATH); + } + + 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,20 +439,29 @@ function renderBoardTabs() { await renderBoard(); }); - const closeButton = document.createElement('button'); - closeButton.type = 'button'; - closeButton.classList.add('board-tab-close'); - closeButton.setAttribute('aria-label', `Close ${getBoardLabelFromPath(boardPath)} board`); - closeButton.title = `Close ${getBoardLabelFromPath(boardPath)}`; - closeButton.textContent = '×'; - closeButton.addEventListener('click', async (event) => { - event.preventDefault(); - event.stopPropagation(); - await closeBoardTab(boardPath); - }); - boardTab.appendChild(tabButton); - boardTab.appendChild(closeButton); + + if (boardPath !== UNIFIED_BOARD_PATH) { + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.classList.add('board-tab-close'); + closeButton.setAttribute('aria-label', `Close ${getBoardLabelFromPath(boardPath)} board`); + closeButton.title = `Close ${getBoardLabelFromPath(boardPath)}`; + closeButton.textContent = '×'; + closeButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await closeBoardTab(boardPath); + }); + boardTab.appendChild(closeButton); + } else { + // Give Unified tab a special icon or class + const icon = document.createElement('span'); + icon.className = 'board-tab-unified-icon'; + icon.innerHTML = ''; + tabButton.prepend(icon); + } + tabsEl.appendChild(boardTab); } @@ -463,4 +493,8 @@ function renderBoardTabs() { tabsEl.appendChild(addBoardTab); 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..d3a0582 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}`, + }); + } } } diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 88a3768..bf48687 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -314,6 +314,7 @@ async function renderBoard() { const requestId = renderState.requestId + 1; renderState.requestId = requestId; const boardRoot = window.boardRoot; // set in the drop-zone handler + const UNIFIED_BOARD_PATH = '__unified__'; closeCardLabelPopover(); if (typeof closeListActionsPopover === 'function') { @@ -340,14 +341,31 @@ 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; + const openBoards = isUnified ? (typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []) : [boardRoot]; + + // For Unified view, we don't have a single board name + const boardName = isUnified ? 'All Boards' : await window.board.getBoardName(boardRoot); + + 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 (!isCurrentBoardRenderRequest(requestId)) { - return; + if (isUnified && openBoards.length === 0) { + renderBoardEmptyState(); + return; } const activeBoardView = typeof getActiveBoardView === 'function' @@ -356,7 +374,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 +402,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 +428,50 @@ 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 + })); + } 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) + }; + }) + ); + } const listBuilds = await Promise.all( - listsWithCards.map(({ listName, listPath, cards }) => createListElement(listName, listPath, cards, { + listsWithCards.map(({ listName, listPath, cardPaths, isUnified }) => createListElement(listName, listPath, cardPaths, { deferSortableInit: true, + isUnified })) ); @@ -460,31 +513,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/lists/createListElement.js b/app/lists/createListElement.js index ac0c89f..59bed58 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -1,4 +1,5 @@ -async function createListElement(name, listPath, cardNames, options = {}) { +async function createListElement(name, listPath, cardPaths, options = {}) { + const isUnified = options.isUnified === true; const listEl = document.createElement('div'); listEl.className = 'list'; listEl.dataset.path = listPath; @@ -6,47 +7,57 @@ async function createListElement(name, listPath, cardNames, options = {}) { 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'); @@ -55,7 +66,7 @@ async function createListElement(name, listPath, cardNames, options = {}) { listEl.appendChild(cardsEl); const cardElements = await Promise.all( - cardNames.map((cardName) => createCardElement(listPath + '/' + cardName)) + cardPaths.map((cardPath) => createCardElement(cardPath)) ); for (const cardEl of cardElements) { diff --git a/app/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index e4dfc59..852a356 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -1031,12 +1031,26 @@ 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 openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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 +1059,7 @@ async function updateCardEditorListDropdown(cardPath) { return; } - const listPaths = await getOrderedListPaths(); + const listPaths = await getOrderedListPaths(cardPath); const currentListPath = getCardListPath(cardPath); listSelect.innerHTML = ''; @@ -1077,7 +1091,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/main.js b/main.js index 3a7ac03..ab2b1d8 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'); } @@ -2069,12 +2077,24 @@ ipcMain.handle('board-call', async (event, payload = {}) => { throw new Error('INVALID_PATH'); } - const senderState = getSenderBoardAccessState(event.sender); + const senderState = getSenderBoardAccessState(sender); 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); diff --git a/static/styles.css b/static/styles.css index 330121a..23ba2d4 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3450,3 +3450,40 @@ 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)); +} + +.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); +} From 12f22b0875ab8c576f6888007b491b6bd1a22af8 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 12:43:20 +0200 Subject: [PATCH 02/21] feat: add board name indicators to cards in unified view - Show board name on Kanban cards in unified mode - Add board name badges to temporal cards (calendar/week) in unified mode - Styling for board indicators --- app/board/boardViews.js | 21 +++++++++++++++++++++ app/cards/createCardElement.js | 22 ++++++++++++++++++++++ static/styles.css | 28 ++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/app/board/boardViews.js b/app/board/boardViews.js index d3a0582..8a27fc8 100644 --- a/app/board/boardViews.js +++ b/app/board/boardViews.js @@ -750,6 +750,27 @@ 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 openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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/cards/createCardElement.js b/app/cards/createCardElement.js index 4713452..87d7b43 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -69,6 +69,28 @@ 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 openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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/static/styles.css b/static/styles.css index 23ba2d4..97a64c0 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3487,3 +3487,31 @@ select:disabled { 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; +} From bf36c3c0e30070e1e53058f3e616019d712d29ca Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 12:53:30 +0200 Subject: [PATCH 03/21] feat: make unified board view an optional tab - Do not automatically prepend 'All Boards' tab when multiple boards are open. - Add an 'All Boards' button next to the 'Add Board' button. - Treat '__unified__' as a regular tab in 'openBoards' state to fix the Sortable drag duplication bug and allow native tab closure. - Filter out the '__unified__' path globally when doing file system operations over 'openBoards'. --- app/board/boardLabels.js | 11 +++-- app/board/boardTabs.js | 71 ++++++++++++++++++++++--------- app/board/boardViews.js | 3 +- app/board/renderBoard.js | 9 +++- app/cards/createCardElement.js | 3 +- app/init.js | 7 ++- app/modals/toggleEditCardModal.js | 3 +- 7 files changed, 78 insertions(+), 29 deletions(-) diff --git a/app/board/boardLabels.js b/app/board/boardLabels.js index 7cb53ed..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) { @@ -2342,7 +2346,8 @@ async function ensureBoardLabelsLoaded() { } if (window.boardRoot === UNIFIED_BOARD_PATH) { - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const storedBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + const openBoards = storedBoards.filter(b => b !== UNIFIED_BOARD_PATH); const allLabels = []; const seenLabelIds = new Set(); diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index 3454e84..ce1276d 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -379,9 +379,6 @@ function renderBoardTabs() { const activeBoard = normalizeBoardPath(window.boardRoot || getStoredActiveBoard()); const boardsToDisplay = [...openBoards]; - if (openBoards.length > 1) { - boardsToDisplay.unshift(UNIFIED_BOARD_PATH); - } for (const boardPath of boardsToDisplay) { const boardTab = document.createElement('div'); @@ -439,29 +436,28 @@ function renderBoardTabs() { await renderBoard(); }); - boardTab.appendChild(tabButton); - - if (boardPath !== UNIFIED_BOARD_PATH) { - const closeButton = document.createElement('button'); - closeButton.type = 'button'; - closeButton.classList.add('board-tab-close'); - closeButton.setAttribute('aria-label', `Close ${getBoardLabelFromPath(boardPath)} board`); - closeButton.title = `Close ${getBoardLabelFromPath(boardPath)}`; - closeButton.textContent = '×'; - closeButton.addEventListener('click', async (event) => { - event.preventDefault(); - event.stopPropagation(); - await closeBoardTab(boardPath); - }); - boardTab.appendChild(closeButton); - } else { - // Give Unified tab a special icon or class + 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'); + closeButton.setAttribute('aria-label', `Close ${getBoardLabelFromPath(boardPath)} board`); + closeButton.title = `Close ${getBoardLabelFromPath(boardPath)}`; + closeButton.textContent = '×'; + closeButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await closeBoardTab(boardPath); + }); + boardTab.appendChild(closeButton); + tabsEl.appendChild(boardTab); } @@ -492,6 +488,41 @@ 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'); + 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') { diff --git a/app/board/boardViews.js b/app/board/boardViews.js index 8a27fc8..d65b069 100644 --- a/app/board/boardViews.js +++ b/app/board/boardViews.js @@ -752,7 +752,8 @@ function createTemporalCardElement(cardEntry, isoDate, className) { const UNIFIED_BOARD_PATH = '__unified__'; if (window.boardRoot === UNIFIED_BOARD_PATH) { - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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)) { diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index bf48687..19002b1 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -342,7 +342,14 @@ async function renderBoard() { try { const boardNameEl = document.getElementById('boardName'); const isUnified = boardRoot === UNIFIED_BOARD_PATH; - const openBoards = isUnified ? (typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []) : [boardRoot]; + + 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); diff --git a/app/cards/createCardElement.js b/app/cards/createCardElement.js index 87d7b43..7cb2726 100644 --- a/app/cards/createCardElement.js +++ b/app/cards/createCardElement.js @@ -74,7 +74,8 @@ async function createCardElement(cardPath) { const boardIndicator = document.createElement('div'); boardIndicator.className = 'card-board-indicator'; - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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)) { 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/modals/toggleEditCardModal.js b/app/modals/toggleEditCardModal.js index 852a356..6871a1d 100644 --- a/app/modals/toggleEditCardModal.js +++ b/app/modals/toggleEditCardModal.js @@ -1037,7 +1037,8 @@ async function getOrderedListPaths(cardPath) { if (boardRoot === UNIFIED_BOARD_PATH && cardPath) { // Derive board root from card path - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + 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; From 79fba2c17bea67840f5af5ff2b5e4c1d08e3c803 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 13:32:04 +0200 Subject: [PATCH 04/21] fix: restore task movement functionality and handle unified state correctly - Fix ReferenceError: sender is not defined in moveCard/moveList IPC handlers - Update authorizeTrustedBoardRootForSender to handle '__unified__' path - Derive real board root in recordCardListMove IPC handler when in Unified view - Clean up unnecessary awaits in createListElement.js --- app/lists/createListElement.js | 4 ++-- main.js | 27 +++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 59bed58..59ab55c 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -116,7 +116,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { adjustedFrom = filePath.replace('.md','.tmp'); } - let adjustedTo = evt.to.dataset.path + '/' + fileNumber + await window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); + let adjustedTo = evt.to.dataset.path + '/' + fileNumber + window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); await window.board.moveCard(adjustedFrom, adjustedTo); if (movedCardOriginalPath && filePath === movedCardOriginalPath) { @@ -156,7 +156,7 @@ async function createListElement(name, listPath, cardPaths, 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/main.js b/main.js index ab2b1d8..6688d42 100644 --- a/main.js +++ b/main.js @@ -623,9 +623,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' }; } @@ -2062,10 +2062,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); } @@ -2077,7 +2096,7 @@ ipcMain.handle('board-call', async (event, payload = {}) => { throw new Error('INVALID_PATH'); } - const senderState = getSenderBoardAccessState(sender); + const senderState = getSenderBoardAccessState(event.sender); const activeBoardRoot = senderState.activeBoardRoot; const movingBoardRoot = sourcePath === activeBoardRoot; From bdeae067016c3c3521990a03a07928922eff46f8 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 13:39:43 +0200 Subject: [PATCH 05/21] feat: improve unified drag-and-drop and implement drag-to-tab - Preserve card's original board when dragging between lists in Unified view - Implement 'drag card to board tab' to move cards between boards - Visual feedback for tab drop targets - Reordering in Unified view only affects cards from the same board --- app/board/boardTabs.js | 89 ++++++++++++++++++++++++++++++++++ app/board/renderBoard.js | 11 +++-- app/lists/createListElement.js | 60 ++++++++++++++++++++--- static/styles.css | 6 +++ 4 files changed, 154 insertions(+), 12 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index ce1276d..85a4897 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -445,6 +445,95 @@ function renderBoardTabs() { boardTab.appendChild(tabButton); + // Card drag and drop to tabs + boardTab.addEventListener('dragover', (e) => { + // Sortable.active.options.group.name check is a way to see if we are dragging a card + if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { + const draggedItem = Sortable.active.dragged; + const cardPath = draggedItem.getAttribute('data-path'); + // Don't highlight if the card already belongs to this board + if (boardPath !== UNIFIED_BOARD_PATH && cardPath.startsWith(boardPath)) { + return; + } + + e.preventDefault(); + boardTab.classList.add('board-tab--drop-target'); + } + }); + + boardTab.addEventListener('dragleave', () => { + boardTab.classList.remove('board-tab--drop-target'); + }); + + boardTab.addEventListener('drop', async (e) => { + boardTab.classList.remove('board-tab--drop-target'); + if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { + e.preventDefault(); + const draggedItem = Sortable.active.dragged; + const cardPath = draggedItem.getAttribute('data-path'); + const sourceListPath = draggedItem.parentElement.getAttribute('data-path'); + const sourceListDisplayName = draggedItem.parentElement.getAttribute('data-displayName'); + + if (!cardPath || boardPath === UNIFIED_BOARD_PATH) { + return; + } + + // If dropping on same board, do nothing + if (cardPath.startsWith(boardPath)) { + return; + } + + // Find the best list on target board + const targetLists = await window.board.listLists(boardPath); + let targetListName = ''; + + // Try to match by display name + for (const list of targetLists) { + if (list.displayName === sourceListDisplayName) { + targetListName = list.directoryName; + break; + } + } + + // Fallback to first non-archive list + if (!targetListName) { + const nonArchive = targetLists.filter(l => !l.isArchive); + if (nonArchive.length > 0) { + targetListName = nonArchive[0].directoryName; + } + } + + if (!targetListName) { + return; + } + + const targetListPath = boardPath + targetListName; + const cardFileName = window.board.getCardFileName(cardPath); + + // Ensure unique name by generating a new prefix if needed + // But moveCard just takes a destination path. + // We'll use insertCardFileAtTop logic implicitly if we could, + // but moveCard is lower level. + // For simplicity, we just append to the list. + const nextPrefix = await window.board.listCards(targetListPath).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 newCardFileName = nextPrefix + cardFileName.slice(3); + const destinationPath = targetListPath + '/' + newCardFileName; + + await window.board.moveCard(cardPath, destinationPath); + + if (typeof window.board.recordCardListMove === 'function') { + await window.board.recordCardListMove(destinationPath, sourceListPath, targetListPath); + } + + await renderBoard(); + } + }); + const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.classList.add('board-tab-close'); diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 19002b1..0e421c5 100644 --- a/app/board/renderBoard.js +++ b/app/board/renderBoard.js @@ -457,7 +457,8 @@ async function renderBoard() { listName: entry.listName, listPath: entry.listPath, cardPaths: entry.cardPaths, - isUnified: true + isUnified: true, + displayName: entry.displayName })); } else { const lists = await window.board.listLists(boardRoot); @@ -469,16 +470,18 @@ async function renderBoard() { return { listName, listPath, - cardPaths: cards.map(c => listPath + '/' + c) + cardPaths: cards.map(c => listPath + '/' + c), + displayName: typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(listName) : listName }; }) ); } const listBuilds = await Promise.all( - listsWithCards.map(({ listName, listPath, cardPaths, isUnified }) => createListElement(listName, listPath, cardPaths, { + listsWithCards.map(({ listName, listPath, cardPaths, isUnified, displayName }) => createListElement(listName, listPath, cardPaths, { deferSortableInit: true, - isUnified + isUnified, + displayName })) ); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 59ab55c..556f0cc 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -1,8 +1,13 @@ async function createListElement(name, listPath, cardPaths, options = {}) { 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'; @@ -63,6 +68,10 @@ async function createListElement(name, listPath, cardPaths, options = {}) { 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( @@ -86,22 +95,59 @@ async function createListElement(name, listPath, cardPaths, options = {}) { onEnd: async (evt) => { 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 : ''; + let targetListPath = evt && evt.to ? evt.to.dataset.path : ''; + const targetIsUnified = evt.to.dataset.isUnified === 'true'; + const targetDisplayName = evt.to.dataset.displayName; + + // Resolve physical target list if in unified view + if (targetIsUnified && movedCardOriginalPath) { + const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; + let cardBoardRoot = ''; + for (const root of openBoards) { + if (movedCardOriginalPath.startsWith(root)) { + cardBoardRoot = root; + break; + } + } + + if (cardBoardRoot) { + const boardLists = await window.board.listLists(cardBoardRoot); + for (const boardListName of boardLists) { + const boardListDisplayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(boardListName) : boardListName; + if (boardListDisplayName === targetDisplayName) { + targetListPath = cardBoardRoot + boardListName; + break; + } + } + } + } + + if (!targetListPath) { + await renderBoard(); + return; + } 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); + // In unified view, we only want to reorder cards that belong to THIS physical list + const cardsBelongingToThisList = finalOrder.filter(path => path.includes(targetListPath)); + + const allCardsInPhysicalList = await window.board.listCards(targetListPath); 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')); + for (const fileName of allCardsInPhysicalList) { + await window.board.moveCard(targetListPath + '/' + fileName, targetListPath + '/' + fileName.replace('.md','.tmp')); } let fileCounter = 0; let movedCardNextPath = ''; for (const filePath of finalOrder) { + // If we are in unified view, we skip reordering for cards NOT in this list's board + if (targetIsUnified && !filePath.includes(targetListPath)) { + continue; + } let fileNumber = (fileCounter).toLocaleString('en-US', { minimumIntegerDigits: 3, @@ -110,13 +156,13 @@ async function createListElement(name, listPath, cardPaths, options = {}) { let adjustedFrom; - if ( !filePath.includes( evt.to.dataset.path ) ) { + if ( !filePath.includes( targetListPath ) ) { adjustedFrom = filePath; } else { adjustedFrom = filePath.replace('.md','.tmp'); } - let adjustedTo = evt.to.dataset.path + '/' + fileNumber + window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); + let adjustedTo = targetListPath + '/' + fileNumber + window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); await window.board.moveCard(adjustedFrom, adjustedTo); if (movedCardOriginalPath && filePath === movedCardOriginalPath) { @@ -124,7 +170,6 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } fileCounter++; - } if ( @@ -139,7 +184,6 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } await renderBoard(); - } })); }; diff --git a/static/styles.css b/static/styles.css index 97a64c0..b17e882 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3478,6 +3478,12 @@ select:disabled { 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; +} + .list--unified { border-style: dashed; border-color: color-mix(in oklab, var(--accent) 30%, var(--border)); From 3a5f92011ea565874ced78b4e6491479b6752625 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 13:47:28 +0200 Subject: [PATCH 06/21] fix: improve drag card to board tab detection - Use elementFromPoint hit-testing in onEnd to detect drops on tabs (fixes issue where native drop event didn't fire due to Sortable's forceFallback) - Use mouseover/mouseout for tab highlighting during card drag - Ensure drop-target highlight is cleared on drag end --- app/board/boardTabs.js | 94 +++++----------------------------- app/lists/createListElement.js | 49 ++++++++++++++++++ app/utilities/cardDragTilt.js | 4 ++ 3 files changed, 65 insertions(+), 82 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index 85a4897..d27d221 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -394,6 +394,7 @@ function renderBoardTabs() { const tabButton = document.createElement('button'); tabButton.type = 'button'; tabButton.classList.add('board-tab-label'); + tabButton.style.pointerEvents = 'none'; // Ensure hit test finds the parent .board-tab tabButton.textContent = getBoardLabelFromPath(boardPath); tabButton.setAttribute('role', 'tab'); tabButton.setAttribute('aria-selected', boardPath === activeBoard ? 'true' : 'false'); @@ -445,93 +446,22 @@ function renderBoardTabs() { boardTab.appendChild(tabButton); - // Card drag and drop to tabs - boardTab.addEventListener('dragover', (e) => { - // Sortable.active.options.group.name check is a way to see if we are dragging a card - if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { - const draggedItem = Sortable.active.dragged; - const cardPath = draggedItem.getAttribute('data-path'); - // Don't highlight if the card already belongs to this board - if (boardPath !== UNIFIED_BOARD_PATH && cardPath.startsWith(boardPath)) { - return; + // Card drag and drop highlighting + boardTab.addEventListener('mouseover', () => { + if (document.body.classList.contains('board-card-drag-active')) { + if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { + const draggedItem = Sortable.active.dragged; + const cardPath = draggedItem.getAttribute('data-path'); + if (boardPath !== UNIFIED_BOARD_PATH && cardPath.startsWith(boardPath)) { + return; + } + boardTab.classList.add('board-tab--drop-target'); } - - e.preventDefault(); - boardTab.classList.add('board-tab--drop-target'); } }); - boardTab.addEventListener('dragleave', () => { - boardTab.classList.remove('board-tab--drop-target'); - }); - - boardTab.addEventListener('drop', async (e) => { + boardTab.addEventListener('mouseout', () => { boardTab.classList.remove('board-tab--drop-target'); - if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { - e.preventDefault(); - const draggedItem = Sortable.active.dragged; - const cardPath = draggedItem.getAttribute('data-path'); - const sourceListPath = draggedItem.parentElement.getAttribute('data-path'); - const sourceListDisplayName = draggedItem.parentElement.getAttribute('data-displayName'); - - if (!cardPath || boardPath === UNIFIED_BOARD_PATH) { - return; - } - - // If dropping on same board, do nothing - if (cardPath.startsWith(boardPath)) { - return; - } - - // Find the best list on target board - const targetLists = await window.board.listLists(boardPath); - let targetListName = ''; - - // Try to match by display name - for (const list of targetLists) { - if (list.displayName === sourceListDisplayName) { - targetListName = list.directoryName; - break; - } - } - - // Fallback to first non-archive list - if (!targetListName) { - const nonArchive = targetLists.filter(l => !l.isArchive); - if (nonArchive.length > 0) { - targetListName = nonArchive[0].directoryName; - } - } - - if (!targetListName) { - return; - } - - const targetListPath = boardPath + targetListName; - const cardFileName = window.board.getCardFileName(cardPath); - - // Ensure unique name by generating a new prefix if needed - // But moveCard just takes a destination path. - // We'll use insertCardFileAtTop logic implicitly if we could, - // but moveCard is lower level. - // For simplicity, we just append to the list. - const nextPrefix = await window.board.listCards(targetListPath).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 newCardFileName = nextPrefix + cardFileName.slice(3); - const destinationPath = targetListPath + '/' + newCardFileName; - - await window.board.moveCard(cardPath, destinationPath); - - if (typeof window.board.recordCardListMove === 'function') { - await window.board.recordCardListMove(destinationPath, sourceListPath, targetListPath); - } - - await renderBoard(); - } }); const closeButton = document.createElement('button'); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 556f0cc..024d6a9 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -99,6 +99,55 @@ async function createListElement(name, listPath, cardPaths, options = {}) { const targetIsUnified = evt.to.dataset.isUnified === 'true'; const targetDisplayName = evt.to.dataset.displayName; + // Check if dropped on a board tab + const originalEvent = evt.originalEvent; + const clientX = originalEvent.clientX || (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientX : 0); + const clientY = originalEvent.clientY || (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientY : 0); + const dropTarget = document.elementFromPoint(clientX, clientY); + const boardTab = dropTarget ? dropTarget.closest('.board-tab[data-board-path]') : null; + + if (boardTab) { + const targetBoardPath = boardTab.getAttribute('data-board-path'); + const UNIFIED_BOARD_PATH = '__unified__'; + + if (targetBoardPath && targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { + // Move card to different board + const targetLists = await window.board.listLists(targetBoardPath); + let targetListName = ''; + const sourceListDisplayName = evt.from.dataset.displayName; + + for (const list of targetLists) { + if (list.displayName === sourceListDisplayName) { + targetListName = list.directoryName; + break; + } + } + + if (!targetListName) { + const nonArchive = targetLists.filter(l => !l.isArchive); + if (nonArchive.length > 0) targetListName = nonArchive[0].directoryName; + } + + 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); + + await window.board.moveCard(movedCardOriginalPath, destinationPath); + if (typeof window.board.recordCardListMove === 'function') { + await window.board.recordCardListMove(destinationPath, sourceListPath, finalTargetListPath); + } + await renderBoard(); + return; + } + } + } + // Resolve physical target list if in unified view if (targetIsUnified && movedCardOriginalPath) { const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; diff --git a/app/utilities/cardDragTilt.js b/app/utilities/cardDragTilt.js index 221532a..3269c63 100644 --- a/app/utilities/cardDragTilt.js +++ b/app/utilities/cardDragTilt.js @@ -32,6 +32,10 @@ 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'); + } } function isBoardCardDragTiltElement(element) { From c84d0a61803597110bd2408c7d40f5e92972db36 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 13:54:35 +0200 Subject: [PATCH 07/21] fix: restore tab switching and improve drag-to-tab reliability - Remove pointer-events: none from tab buttons (restores clicking tabs) - Add pointer-events: none to drag ghost element (fixes hit-testing tabs underneath) - Improve coordinate detection for elementFromPoint hit-test --- app/board/boardTabs.js | 1 - app/lists/createListElement.js | 4 ++-- static/styles.css | 4 ++++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index d27d221..918fdd5 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -394,7 +394,6 @@ function renderBoardTabs() { const tabButton = document.createElement('button'); tabButton.type = 'button'; tabButton.classList.add('board-tab-label'); - tabButton.style.pointerEvents = 'none'; // Ensure hit test finds the parent .board-tab tabButton.textContent = getBoardLabelFromPath(boardPath); tabButton.setAttribute('role', 'tab'); tabButton.setAttribute('aria-selected', boardPath === activeBoard ? 'true' : 'false'); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 024d6a9..0e6b7ba 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -101,8 +101,8 @@ async function createListElement(name, listPath, cardPaths, options = {}) { // Check if dropped on a board tab const originalEvent = evt.originalEvent; - const clientX = originalEvent.clientX || (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientX : 0); - const clientY = originalEvent.clientY || (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientY : 0); + const clientX = originalEvent.clientX !== undefined ? originalEvent.clientX : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientX : (originalEvent.pageX - window.scrollX)); + const clientY = originalEvent.clientY !== undefined ? originalEvent.clientY : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientY : (originalEvent.pageY - window.scrollY)); const dropTarget = document.elementFromPoint(clientX, clientY); const boardTab = dropTarget ? dropTarget.closest('.board-tab[data-board-path]') : null; diff --git a/static/styles.css b/static/styles.css index b17e882..e4a10ac 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3484,6 +3484,10 @@ select:disabled { background: color-mix(in oklab, var(--accent) 15%, var(--bg-card)) !important; } +.card-sortable--fallback { + pointer-events: none !important; +} + .list--unified { border-style: dashed; border-color: color-mix(in oklab, var(--accent) 30%, var(--border)); From c6002cf8fa24b271ee9aa332ea2bd9cf5c075b24 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 14:04:25 +0200 Subject: [PATCH 08/21] fix: improve unified reordering and drag-to-tab visual feedback - Use resolved targetListPath for reordering in Unified view (fixes cards jumping to leftmost board) - Hide/remove card immediately when dropping on a tab to prevent 'stuck' visual state - Switch to target board after dropping card on its tab - Add safety checks for card paths in reordering loop --- app/lists/createListElement.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 0e6b7ba..4fa6fa7 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -138,10 +138,22 @@ async function createListElement(name, listPath, cardPaths, options = {}) { }); const destinationPath = finalTargetListPath + '/' + nextPrefix + cardFileName.slice(3); + // Remove visually before we do the work to prevent "stuck" look + if (evt.item && evt.item.parentNode) { + evt.item.parentNode.removeChild(evt.item); + } + await window.board.moveCard(movedCardOriginalPath, destinationPath); if (typeof window.board.recordCardListMove === 'function') { await window.board.recordCardListMove(destinationPath, sourceListPath, finalTargetListPath); } + + // Switch to the target board + window.boardRoot = targetBoardPath; + if (typeof setStoredActiveBoard === 'function') { + setStoredActiveBoard(targetBoardPath); + } + await renderBoard(); return; } @@ -181,7 +193,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { ); // In unified view, we only want to reorder cards that belong to THIS physical list - const cardsBelongingToThisList = finalOrder.filter(path => path.includes(targetListPath)); + const cardsBelongingToThisList = finalOrder.filter(path => path && path.includes(targetListPath)); const allCardsInPhysicalList = await window.board.listCards(targetListPath); @@ -194,7 +206,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { let movedCardNextPath = ''; for (const filePath of finalOrder) { // If we are in unified view, we skip reordering for cards NOT in this list's board - if (targetIsUnified && !filePath.includes(targetListPath)) { + if (targetIsUnified && (!filePath || !filePath.includes(targetListPath))) { continue; } @@ -205,7 +217,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { let adjustedFrom; - if ( !filePath.includes( targetListPath ) ) { + if ( !filePath || !filePath.includes( targetListPath ) ) { adjustedFrom = filePath; } else { adjustedFrom = filePath.replace('.md','.tmp'); From fcd66c91923e28cf8943ee4ba1a7f8483d58bc4a Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 14:06:33 +0200 Subject: [PATCH 09/21] fix: reliable drag-to-tab detection and restore tab switching - Use hover class tracking (.board-tab--drop-target) for drop detection instead of elementFromPoint - Ensure tab icons and buttons don't block tab switching clicks - Fix visual stuck card when dragging between boards --- app/lists/createListElement.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 4fa6fa7..545a4ab 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -99,12 +99,8 @@ async function createListElement(name, listPath, cardPaths, options = {}) { const targetIsUnified = evt.to.dataset.isUnified === 'true'; const targetDisplayName = evt.to.dataset.displayName; - // Check if dropped on a board tab - const originalEvent = evt.originalEvent; - const clientX = originalEvent.clientX !== undefined ? originalEvent.clientX : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientX : (originalEvent.pageX - window.scrollX)); - const clientY = originalEvent.clientY !== undefined ? originalEvent.clientY : (originalEvent.touches && originalEvent.touches[0] ? originalEvent.touches[0].clientY : (originalEvent.pageY - window.scrollY)); - const dropTarget = document.elementFromPoint(clientX, clientY); - const boardTab = dropTarget ? dropTarget.closest('.board-tab[data-board-path]') : null; + // Check if dropped on a board tab - use the highlight class we set in mouseover + const boardTab = document.querySelector('.board-tab--drop-target'); if (boardTab) { const targetBoardPath = boardTab.getAttribute('data-board-path'); From 32c15376693f975bdcc6fc69c068c3047103f144 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 14:38:05 +0200 Subject: [PATCH 10/21] fix: robust reordering and reliable drag-to-tab detection - Use global mousemove listener for reliable tab highlighting during drag - Ensure board preservation in Unified view by resolving or creating lists on card's original board - Fixed visual 'stuck' card when moving between boards - Removed duplicated code in createListElement.js --- app/board/boardTabs.js | 45 ++++++++------ app/lists/createListElement.js | 104 ++++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 52 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index 918fdd5..ce4a6df 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -445,24 +445,6 @@ function renderBoardTabs() { boardTab.appendChild(tabButton); - // Card drag and drop highlighting - boardTab.addEventListener('mouseover', () => { - if (document.body.classList.contains('board-card-drag-active')) { - if (typeof Sortable !== 'undefined' && Sortable.active && Sortable.active.options.group.name === 'cards') { - const draggedItem = Sortable.active.dragged; - const cardPath = draggedItem.getAttribute('data-path'); - if (boardPath !== UNIFIED_BOARD_PATH && cardPath.startsWith(boardPath)) { - return; - } - boardTab.classList.add('board-tab--drop-target'); - } - } - }); - - boardTab.addEventListener('mouseout', () => { - boardTab.classList.remove('board-tab--drop-target'); - }); - const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.classList.add('board-tab-close'); @@ -479,6 +461,33 @@ function renderBoardTabs() { tabsEl.appendChild(boardTab); } + // Initialize global drag tracking for tabs if not already done + 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; + + document.querySelectorAll('.board-tab--drop-target').forEach(el => { + if (el !== boardTab) el.classList.remove('board-tab--drop-target'); + }); + + if (boardTab) { + 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; + const targetBoardPath = boardTab.getAttribute('data-board-path'); + if (targetBoardPath !== '__unified__' && cardPath && cardPath.startsWith(targetBoardPath)) { + return; + } + boardTab.classList.add('board-tab--drop-target'); + } + } + } + }); + } + const addBoardTab = document.createElement('div'); addBoardTab.classList.add('board-tab', 'board-tab-add'); addBoardTab.setAttribute('role', 'presentation'); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 545a4ab..2285103 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -95,11 +95,10 @@ async function createListElement(name, listPath, cardPaths, options = {}) { onEnd: async (evt) => { const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; - let targetListPath = evt && evt.to ? evt.to.dataset.path : ''; const targetIsUnified = evt.to.dataset.isUnified === 'true'; const targetDisplayName = evt.to.dataset.displayName; - // Check if dropped on a board tab - use the highlight class we set in mouseover + // 1. Detect if dropped on a board tab (Drag-to-Tab) const boardTab = document.querySelector('.board-tab--drop-target'); if (boardTab) { @@ -134,7 +133,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { }); const destinationPath = finalTargetListPath + '/' + nextPrefix + cardFileName.slice(3); - // Remove visually before we do the work to prevent "stuck" look + // Remove visually immediately if (evt.item && evt.item.parentNode) { evt.item.parentNode.removeChild(evt.item); } @@ -156,26 +155,37 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } } - // Resolve physical target list if in unified view + // 2. Resolve target list path (preserving board if in Unified view) + let targetListPath = evt.to.dataset.path; + if (targetIsUnified && movedCardOriginalPath) { const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; - let cardBoardRoot = ''; - for (const root of openBoards) { - if (movedCardOriginalPath.startsWith(root)) { - cardBoardRoot = root; - break; - } - } + const actualBoards = openBoards.filter(b => b !== '__unified__'); + let cardBoardRoot = actualBoards.find(root => movedCardOriginalPath.startsWith(root)); if (cardBoardRoot) { const boardLists = await window.board.listLists(cardBoardRoot); + let foundList = null; for (const boardListName of boardLists) { const boardListDisplayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(boardListName) : boardListName; if (boardListDisplayName === targetDisplayName) { - targetListPath = cardBoardRoot + boardListName; + 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; + } } } @@ -185,44 +195,70 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => - card.getAttribute('data-path') // array of CURRENT filenames in final order + card.getAttribute('data-path') ); - // In unified view, we only want to reorder cards that belong to THIS physical list - const cardsBelongingToThisList = finalOrder.filter(path => path && path.includes(targetListPath)); + // 3. Robust reordering + // Filter out cards NOT belonging to the physical target board/list + const cardsForThisPhysicalList = finalOrder.filter(path => path && path.startsWith(targetListPath.replace(/\/$/, ''))); + + // If we moved a card from another list/board, it is now in finalOrder but its path is the original one + // We need to ensure it's included in the reordering if it's supposed to land in this physical list. - const allCardsInPhysicalList = await window.board.listCards(targetListPath); + const allCardsInTargetPhysicalList = await window.board.listCards(targetListPath); - let tempFileCounter = 0; - for (const fileName of allCardsInPhysicalList) { - await window.board.moveCard(targetListPath + '/' + fileName, targetListPath + '/' + fileName.replace('.md','.tmp')); + // 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 we are in unified view, we skip reordering for cards NOT in this list's board - if (targetIsUnified && (!filePath || !filePath.includes(targetListPath))) { + if (!filePath) continue; + + const belongsToThisBoard = filePath.startsWith(targetListPath.replace(/\/$/, '')); + const isTheMovedCard = (filePath === movedCardOriginalPath); + + // If in unified view, we only reorder cards that belong to this physical list's board + if (targetIsUnified && !belongsToThisBoard && !isTheMovedCard) { + continue; + } + + // If it's the moved card but it's from another board, we still want to move it to THIS board's list? + // No, our resolution above ensured targetListPath matches the card's board if possible. + // If we are here and belongsToThisBoard is false, it means we are moving across boards + // but NOT via a tab drop (just dragging between columns). + // In that case, the resolution above should have already updated targetListPath + // to the matching list ON THE CARD'S ORIGINAL BOARD. + + // Wait, if targetListPath was updated to Card's board, then belongsToThisBoard SHOULD be true + // for existing cards on that board in this column. + + if (!filePath.startsWith(targetListPath.replace(/\/$/, ''))) { + // This card doesn't belong in the physical reordering of targetListPath continue; } - let fileNumber = (fileCounter).toLocaleString('en-US', { - minimumIntegerDigits: 3, - useGrouping: false - }); - - let adjustedFrom; - - if ( !filePath || !filePath.includes( targetListPath ) ) { - adjustedFrom = filePath; - } else { - adjustedFrom = filePath.replace('.md','.tmp'); + 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'); } - let adjustedTo = targetListPath + '/' + fileNumber + window.board.getCardFileName(filePath).slice(3).replace('.tmp','.md'); + const cardFileName = window.board.getCardFileName(filePath); + const nameWithoutPrefix = cardFileName.slice(3).replace('.tmp', '').replace('.md', ''); + const adjustedTo = (targetListPath.endsWith('/') ? targetListPath : targetListPath + '/') + fileNumber + '-' + nameWithoutPrefix + '.md'; await window.board.moveCard(adjustedFrom, adjustedTo); - if (movedCardOriginalPath && filePath === movedCardOriginalPath) { + + if (isTheMovedCard) { movedCardNextPath = adjustedTo; } From f00630eb4218078d986d76a842f4abb6373fa96c Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 14:52:37 +0200 Subject: [PATCH 11/21] fix: robust task movement between lists and boards - Fixed reordering bug in Unified view that skipped the moved card - Improved drag-to-tab reliability using global state tracking (__activeBoardDropTarget) - Ensure drop target state is fully cleared on drag end - Add auto-list creation if dragging to a column that doesn't exist on the card's board --- app/board/boardTabs.js | 41 +++++++++++++++------------------- app/lists/createListElement.js | 34 ++++++---------------------- app/utilities/cardDragTilt.js | 1 + 3 files changed, 26 insertions(+), 50 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index ce4a6df..b25499f 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -458,34 +458,29 @@ function renderBoardTabs() { }); boardTab.appendChild(closeButton); - tabsEl.appendChild(boardTab); - } - - // Initialize global drag tracking for tabs if not already done - if (!window.__tabDragTrackerInitialized) { - window.__tabDragTrackerInitialized = true; - document.addEventListener('mousemove', (e) => { + // Card drag and drop highlighting + boardTab.addEventListener('mouseenter', () => { 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; - - document.querySelectorAll('.board-tab--drop-target').forEach(el => { - if (el !== boardTab) el.classList.remove('board-tab--drop-target'); - }); - - if (boardTab) { - 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; - const targetBoardPath = boardTab.getAttribute('data-board-path'); - if (targetBoardPath !== '__unified__' && cardPath && cardPath.startsWith(targetBoardPath)) { - return; - } - boardTab.classList.add('board-tab--drop-target'); + 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 (boardPath !== UNIFIED_BOARD_PATH && cardPath && cardPath.startsWith(boardPath)) { + return; } + window.__activeBoardDropTarget = boardPath; + boardTab.classList.add('board-tab--drop-target'); } } }); + + boardTab.addEventListener('mouseleave', () => { + if (window.__activeBoardDropTarget === boardPath) { + window.__activeBoardDropTarget = null; + } + boardTab.classList.remove('board-tab--drop-target'); + }); + + tabsEl.appendChild(boardTab); } const addBoardTab = document.createElement('div'); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 2285103..00966fc 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -99,13 +99,12 @@ async function createListElement(name, listPath, cardPaths, options = {}) { const targetDisplayName = evt.to.dataset.displayName; // 1. Detect if dropped on a board tab (Drag-to-Tab) - const boardTab = document.querySelector('.board-tab--drop-target'); + const targetBoardPath = window.__activeBoardDropTarget; - if (boardTab) { - const targetBoardPath = boardTab.getAttribute('data-board-path'); + if (targetBoardPath) { const UNIFIED_BOARD_PATH = '__unified__'; - if (targetBoardPath && targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { + if (targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { // Move card to different board const targetLists = await window.board.listLists(targetBoardPath); let targetListName = ''; @@ -199,12 +198,7 @@ async function createListElement(name, listPath, cardPaths, options = {}) { ); // 3. Robust reordering - // Filter out cards NOT belonging to the physical target board/list - const cardsForThisPhysicalList = finalOrder.filter(path => path && path.startsWith(targetListPath.replace(/\/$/, ''))); - - // If we moved a card from another list/board, it is now in finalOrder but its path is the original one - // We need to ensure it's included in the reordering if it's supposed to land in this physical list. - + 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 @@ -219,26 +213,12 @@ async function createListElement(name, listPath, cardPaths, options = {}) { for (const filePath of finalOrder) { if (!filePath) continue; - const belongsToThisBoard = filePath.startsWith(targetListPath.replace(/\/$/, '')); 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 - if (targetIsUnified && !belongsToThisBoard && !isTheMovedCard) { - continue; - } - - // If it's the moved card but it's from another board, we still want to move it to THIS board's list? - // No, our resolution above ensured targetListPath matches the card's board if possible. - // If we are here and belongsToThisBoard is false, it means we are moving across boards - // but NOT via a tab drop (just dragging between columns). - // In that case, the resolution above should have already updated targetListPath - // to the matching list ON THE CARD'S ORIGINAL BOARD. - - // Wait, if targetListPath was updated to Card's board, then belongsToThisBoard SHOULD be true - // for existing cards on that board in this column. - - if (!filePath.startsWith(targetListPath.replace(/\/$/, ''))) { - // This card doesn't belong in the physical reordering of targetListPath + // OR the card we just moved into this column from another list on the same board + if (!belongsToThisBoard && !isTheMovedCard) { continue; } diff --git a/app/utilities/cardDragTilt.js b/app/utilities/cardDragTilt.js index 3269c63..4911046 100644 --- a/app/utilities/cardDragTilt.js +++ b/app/utilities/cardDragTilt.js @@ -36,6 +36,7 @@ function unlockBoardCardTextSelection() { for (const target of dropTargets) { target.classList.remove('board-tab--drop-target'); } + window.__activeBoardDropTarget = null; } function isBoardCardDragTiltElement(element) { From 136dcc3a3ef5190f69859b727df88d64f5d20ef0 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 15:30:37 +0200 Subject: [PATCH 12/21] fix: final robust drag-to-board and reordering implementation - Fix double-hyphen filename bug during reordering - Implement reliable tab drop detection using window.__activeBoardDropTarget - Add extensive safety checks for null/missing event data in onEnd - Ensure dropping a card on its own board tab works as a navigation shortcut --- app/board/boardTabs.js | 11 ++--------- app/lists/createListElement.js | 25 +++++++++++++++++++------ static/styles.css | 8 ++++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index b25499f..a27b9bd 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -461,15 +461,8 @@ function renderBoardTabs() { // Card drag and drop highlighting boardTab.addEventListener('mouseenter', () => { if (document.body.classList.contains('board-card-drag-active')) { - 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 (boardPath !== UNIFIED_BOARD_PATH && cardPath && cardPath.startsWith(boardPath)) { - return; - } - window.__activeBoardDropTarget = boardPath; - boardTab.classList.add('board-tab--drop-target'); - } + window.__activeBoardDropTarget = boardPath; + boardTab.classList.add('board-tab--drop-target'); } }); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 00966fc..43d5e9b 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -95,8 +95,8 @@ async function createListElement(name, listPath, cardPaths, options = {}) { onEnd: async (evt) => { const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; - const targetIsUnified = evt.to.dataset.isUnified === 'true'; - const targetDisplayName = evt.to.dataset.displayName; + const targetIsUnified = evt && evt.to && evt.to.dataset ? evt.to.dataset.isUnified === 'true' : false; + const targetDisplayName = evt && evt.to && evt.to.dataset ? evt.to.dataset.displayName : ''; // 1. Detect if dropped on a board tab (Drag-to-Tab) const targetBoardPath = window.__activeBoardDropTarget; @@ -104,11 +104,19 @@ async function createListElement(name, listPath, cardPaths, options = {}) { if (targetBoardPath) { const UNIFIED_BOARD_PATH = '__unified__'; - if (targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { + if (targetBoardPath !== UNIFIED_BOARD_PATH) { + // If dropped on own board tab, just switch to it and return + if (movedCardOriginalPath.startsWith(targetBoardPath)) { + window.boardRoot = targetBoardPath; + if (typeof setStoredActiveBoard === 'function') setStoredActiveBoard(targetBoardPath); + await renderBoard(); + return; + } + // Move card to different board const targetLists = await window.board.listLists(targetBoardPath); let targetListName = ''; - const sourceListDisplayName = evt.from.dataset.displayName; + const sourceListDisplayName = evt && evt.from && evt.from.dataset ? evt.from.dataset.displayName : ''; for (const list of targetLists) { if (list.displayName === sourceListDisplayName) { @@ -155,6 +163,11 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } // 2. Resolve target list path (preserving board if in Unified view) + if (!evt || !evt.to) { + await renderBoard(); + return; + } + let targetListPath = evt.to.dataset.path; if (targetIsUnified && movedCardOriginalPath) { @@ -233,8 +246,8 @@ async function createListElement(name, listPath, cardPaths, options = {}) { } const cardFileName = window.board.getCardFileName(filePath); - const nameWithoutPrefix = cardFileName.slice(3).replace('.tmp', '').replace('.md', ''); - const adjustedTo = (targetListPath.endsWith('/') ? targetListPath : targetListPath + '/') + fileNumber + '-' + nameWithoutPrefix + '.md'; + 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); diff --git a/static/styles.css b/static/styles.css index e4a10ac..5782c76 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3488,6 +3488,14 @@ select:disabled { 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)); From f24e200c602c4a3b66ae1222b2e79941e126783b Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 15:36:01 +0200 Subject: [PATCH 13/21] fix: resolve race condition in drag-to-tab and support cross-device moves - Fix race condition where drag state (__activeBoardDropTarget) was cleared before async move handler could read it. - Update createBoardCardSortableOptions to correctly handle and await async onEnd handlers. - Support cross-device board moves in main.js by falling back to copy+unlink if rename fails with EXDEV. --- app/utilities/cardDragTilt.js | 19 ++++++++++++++++--- main.js | 11 ++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/utilities/cardDragTilt.js b/app/utilities/cardDragTilt.js index 4911046..afd6316 100644 --- a/app/utilities/cardDragTilt.js +++ b/app/utilities/cardDragTilt.js @@ -247,13 +247,26 @@ function createBoardCardSortableOptions(options = {}) { beginBoardCardDragTilt(evt); }, onEnd(evt) { + const runCleanup = () => { + endBoardCardDragTilt(evt); + unlockBoardCardTextSelection(); + }; + try { if (typeof baseOnEnd === 'function') { - return baseOnEnd.call(this, evt); + const result = baseOnEnd.call(this, evt); + if (result && typeof result.then === 'function') { + return result.then(runCleanup, (err) => { + runCleanup(); + throw err; + }); + } + return result; } } finally { - endBoardCardDragTilt(evt); - unlockBoardCardTextSelection(); + if (!(baseOnEnd && typeof baseOnEnd.then === 'function')) { + runCleanup(); + } } }, }; diff --git a/main.js b/main.js index 6688d42..a32d178 100644 --- a/main.js +++ b/main.js @@ -2116,7 +2116,16 @@ ipcMain.handle('board-call', async (event, payload = {}) => { } } - 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); From 124c8a5fd3804032669a10502cd1f5e40135077c Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 15:42:56 +0200 Subject: [PATCH 14/21] fix: definitive board-to-board dragging and reordering fixes - Ensure board tab highlight persists during drag to prevent race conditions during drop - Add pointer-events: none to all Sortable helper classes to prevent hit-test interference - Use dual detection (variable + CSS class) for reliable tab drop identification - Fixed filename re-indexing logic to preserve correct board structure --- app/board/boardTabs.js | 13 ++++++++++--- app/lists/createListElement.js | 8 +++++++- app/utilities/cardDragTilt.js | 30 ++++++++++++++++-------------- static/styles.css | 5 ++++- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index a27b9bd..bde1d1a 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -461,16 +461,23 @@ function renderBoardTabs() { // Card drag and drop highlighting boardTab.addEventListener('mouseenter', () => { if (document.body.classList.contains('board-card-drag-active')) { + // Clear any other highlights first + document.querySelectorAll('.board-tab--drop-target').forEach(el => el.classList.remove('board-tab--drop-target')); + window.__activeBoardDropTarget = boardPath; boardTab.classList.add('board-tab--drop-target'); } }); boardTab.addEventListener('mouseleave', () => { - if (window.__activeBoardDropTarget === boardPath) { - window.__activeBoardDropTarget = null; + // ONLY remove if NOT dragging. If dragging, we want to keep the "last hovered" tab + // as the drop target to be robust against slight mouse movements during drop. + if (!document.body.classList.contains('board-card-drag-active')) { + if (window.__activeBoardDropTarget === boardPath) { + window.__activeBoardDropTarget = null; + } + boardTab.classList.remove('board-tab--drop-target'); } - boardTab.classList.remove('board-tab--drop-target'); }); tabsEl.appendChild(boardTab); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 43d5e9b..e38aad6 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -99,7 +99,13 @@ async function createListElement(name, listPath, cardPaths, options = {}) { const targetDisplayName = evt && evt.to && evt.to.dataset ? evt.to.dataset.displayName : ''; // 1. Detect if dropped on a board tab (Drag-to-Tab) - const targetBoardPath = window.__activeBoardDropTarget; + let targetBoardPath = window.__activeBoardDropTarget; + + // Fallback to class-based detection if global variable is missing + if (!targetBoardPath) { + const highlightedTab = document.querySelector('.board-tab--drop-target'); + if (highlightedTab) targetBoardPath = highlightedTab.getAttribute('data-board-path'); + } if (targetBoardPath) { const UNIFIED_BOARD_PATH = '__unified__'; diff --git a/app/utilities/cardDragTilt.js b/app/utilities/cardDragTilt.js index afd6316..0398fbb 100644 --- a/app/utilities/cardDragTilt.js +++ b/app/utilities/cardDragTilt.js @@ -252,21 +252,23 @@ function createBoardCardSortableOptions(options = {}) { unlockBoardCardTextSelection(); }; - try { - if (typeof baseOnEnd === 'function') { - const result = baseOnEnd.call(this, evt); - if (result && typeof result.then === 'function') { - return result.then(runCleanup, (err) => { - runCleanup(); - throw err; - }); + 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; } - return result; - } - } finally { - if (!(baseOnEnd && typeof baseOnEnd.then === 'function')) { - runCleanup(); - } + ); + } else { + runCleanup(); + return result; } }, }; diff --git a/static/styles.css b/static/styles.css index 5782c76..e02fe65 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3484,7 +3484,10 @@ select:disabled { background: color-mix(in oklab, var(--accent) 15%, var(--bg-card)) !important; } -.card-sortable--fallback { +.card-sortable--fallback, +.sortable-fallback, +.sortable-drag, +.sortable-ghost { pointer-events: none !important; } From a93b046fa3fd0cc1ffc7f0949566817393920c7f Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 15:45:19 +0200 Subject: [PATCH 15/21] fix: hardened drag-and-drop and reliable tab drop detection - Use mouseenter/mouseleave with pointer-events protection for tab drops - Add try/catch hardening around all IPC file operations in onEnd - Disable recordCardListMove for cross-board moves to avoid path validation errors - Improve list resolution and reordering logic in Unified view --- app/board/boardTabs.js | 54 ++++--- app/lists/createListElement.js | 260 +++++++++++++++++---------------- main.js | 5 + 3 files changed, 176 insertions(+), 143 deletions(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index bde1d1a..f055850 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -457,30 +457,50 @@ function renderBoardTabs() { await closeBoardTab(boardPath); }); boardTab.appendChild(closeButton); + tabsEl.appendChild(boardTab); + } - // Card drag and drop highlighting - boardTab.addEventListener('mouseenter', () => { + // Global drag tracking for tabs + if (!window.__tabDragTrackerInitialized) { + window.__tabDragTrackerInitialized = true; + document.addEventListener('mousemove', (e) => { if (document.body.classList.contains('board-card-drag-active')) { - // Clear any other highlights first - document.querySelectorAll('.board-tab--drop-target').forEach(el => el.classList.remove('board-tab--drop-target')); + const hit = document.elementFromPoint(e.clientX, e.clientY); + const boardTab = hit ? hit.closest('.board-tab[data-board-path]') : null; - window.__activeBoardDropTarget = boardPath; - boardTab.classList.add('board-tab--drop-target'); - } - }); - - boardTab.addEventListener('mouseleave', () => { - // ONLY remove if NOT dragging. If dragging, we want to keep the "last hovered" tab - // as the drop target to be robust against slight mouse movements during drop. - if (!document.body.classList.contains('board-card-drag-active')) { - if (window.__activeBoardDropTarget === boardPath) { + // 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; } - boardTab.classList.remove('board-tab--drop-target'); + } else { + window.__activeBoardDropTarget = null; } }); - - tabsEl.appendChild(boardTab); } const addBoardTab = document.createElement('div'); diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index e38aad6..d793625 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -94,74 +94,67 @@ async function createListElement(name, listPath, cardPaths, options = {}) { disabled: isBoardLabelFilterActive(), onEnd: async (evt) => { const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; - const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; + const sourceListPath = evt && evt.from && evt.from.dataset ? evt.from.dataset.path : ''; const targetIsUnified = evt && evt.to && evt.to.dataset ? evt.to.dataset.isUnified === 'true' : false; const targetDisplayName = evt && evt.to && evt.to.dataset ? evt.to.dataset.displayName : ''; // 1. Detect if dropped on a board tab (Drag-to-Tab) - let targetBoardPath = window.__activeBoardDropTarget; - - // Fallback to class-based detection if global variable is missing - if (!targetBoardPath) { - const highlightedTab = document.querySelector('.board-tab--drop-target'); - if (highlightedTab) targetBoardPath = highlightedTab.getAttribute('data-board-path'); - } + const targetBoardPath = window.__activeBoardDropTarget; 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.startsWith(targetBoardPath)) { - window.boardRoot = targetBoardPath; - if (typeof setStoredActiveBoard === 'function') setStoredActiveBoard(targetBoardPath); - await renderBoard(); - return; - } - + if (targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { // Move card to different board - const targetLists = await window.board.listLists(targetBoardPath); - let targetListName = ''; - const sourceListDisplayName = evt && evt.from && evt.from.dataset ? evt.from.dataset.displayName : ''; - - for (const list of targetLists) { - if (list.displayName === sourceListDisplayName) { - targetListName = list.directoryName; - break; + try { + const targetLists = await window.board.listLists(targetBoardPath); + let targetListName = ''; + const sourceListDisplayName = evt && evt.from && evt.from.dataset ? evt.from.dataset.displayName : ''; + + for (const list of targetLists) { + if (list.displayName === sourceListDisplayName) { + targetListName = list.directoryName; + break; + } } - } - if (!targetListName) { - const nonArchive = targetLists.filter(l => !l.isArchive); - if (nonArchive.length > 0) targetListName = nonArchive[0].directoryName; - } - - 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); - - // Remove visually immediately - if (evt.item && evt.item.parentNode) { - evt.item.parentNode.removeChild(evt.item); + if (!targetListName) { + const nonArchive = targetLists.filter(l => !l.isArchive); + if (nonArchive.length > 0) targetListName = nonArchive[0].directoryName; } - await window.board.moveCard(movedCardOriginalPath, destinationPath); - if (typeof window.board.recordCardListMove === 'function') { - await window.board.recordCardListMove(destinationPath, sourceListPath, finalTargetListPath); + 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); + + // Remove visually immediately + if (evt.item && evt.item.parentNode) { + evt.item.parentNode.removeChild(evt.item); + } + + await window.board.moveCard(movedCardOriginalPath, destinationPath); + + // recordCardListMove only works if boardRoot matches. Derived boardRoot should match targetBoardPath here. + // But recordCardListMove in lib/archive.js requires card to be inside boardRoot. + // If we are moving BETWEEN boards, we probably should skip recording for now as it's not supported by the lib. + + // Switch to the target board + window.boardRoot = targetBoardPath; + if (typeof setStoredActiveBoard === 'function') { + setStoredActiveBoard(targetBoardPath); + } + + await renderBoard(); + return; } - - // Switch to the target board - window.boardRoot = targetBoardPath; - if (typeof setStoredActiveBoard === 'function') { - setStoredActiveBoard(targetBoardPath); - } - + } catch (e) { + console.error('Failed to move card to another board', e); await renderBoard(); return; } @@ -182,27 +175,33 @@ async function createListElement(name, listPath, cardPaths, options = {}) { let cardBoardRoot = actualBoards.find(root => movedCardOriginalPath.startsWith(root)); if (cardBoardRoot) { - const boardLists = await window.board.listLists(cardBoardRoot); - let foundList = null; - for (const boardListName of boardLists) { - const boardListDisplayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(boardListName) : boardListName; - if (boardListDisplayName === targetDisplayName) { - foundList = boardListName; - break; + try { + const boardLists = await window.board.listLists(cardBoardRoot); + let foundList = null; + for (const boardListName of boardLists) { + const boardListDisplayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(boardListName) : boardListName; + if (boardListDisplayName === targetDisplayName) { + 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; + + 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; } } } @@ -212,67 +211,76 @@ async function createListElement(name, listPath, cardPaths, options = {}) { return; } - const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => - card.getAttribute('data-path') - ); + try { + const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => + card.getAttribute('data-path') + ); - // 3. Robust reordering - const resolvedPathBase = targetListPath.replace(/\/$/, ''); - const allCardsInTargetPhysicalList = await window.board.listCards(targetListPath); + // 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; + // 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 fileNumber = String(fileCounter).padStart(3, '0'); - let adjustedFrom = filePath; + let fileCounter = 0; + let movedCardNextPath = ''; - // 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'); - } + 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; + } - 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'); + 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'); + } - await window.board.moveCard(adjustedFrom, adjustedTo); - - if (isTheMovedCard) { - movedCardNextPath = adjustedTo; - } + 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'); - fileCounter++; - } + await window.board.moveCard(adjustedFrom, adjustedTo); + + if (isTheMovedCard) { + movedCardNextPath = adjustedTo; + } + + fileCounter++; + } - if ( - movedCardNextPath && - sourceListPath && - targetListPath && - sourceListPath !== targetListPath && - window.board && - typeof window.board.recordCardListMove === 'function' - ) { - await window.board.recordCardListMove(movedCardNextPath, sourceListPath, targetListPath); + 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); } await renderBoard(); diff --git a/main.js b/main.js index a32d178..d4651cf 100644 --- a/main.js +++ b/main.js @@ -615,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'); } From 59e6c43cf63afcf188316703d7c19e5afdd47e4f Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 15:48:56 +0200 Subject: [PATCH 16/21] fix: definitive drag-and-drop implementation with manual hit-testing - Use getBoundingClientRect manual hit-testing in onEnd for 100% reliable tab drop detection - Handle __unified__ path in main.js board root validation - Add try/catch blocks to prevent UI hangs on IPC failures - Robust filename re-indexing logic to prevent path corruption - Clean up redundant listeners and improve visual feedback --- app/lists/createListElement.js | 51 ++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index d793625..9c066c9 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -94,17 +94,44 @@ async function createListElement(name, listPath, cardPaths, options = {}) { disabled: isBoardLabelFilterActive(), onEnd: async (evt) => { const movedCardOriginalPath = evt && evt.item ? evt.item.getAttribute('data-path') : ''; - const sourceListPath = evt && evt.from && evt.from.dataset ? evt.from.dataset.path : ''; + const sourceListPath = evt && evt.from ? evt.from.dataset.path : ''; const targetIsUnified = evt && evt.to && evt.to.dataset ? evt.to.dataset.isUnified === 'true' : false; const targetDisplayName = evt && evt.to && evt.to.dataset ? evt.to.dataset.displayName : ''; - // 1. Detect if dropped on a board tab (Drag-to-Tab) - const targetBoardPath = window.__activeBoardDropTarget; + // 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 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; + } + } + } if (targetBoardPath) { const UNIFIED_BOARD_PATH = '__unified__'; - if (targetBoardPath !== UNIFIED_BOARD_PATH && !movedCardOriginalPath.startsWith(targetBoardPath)) { + 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); @@ -133,23 +160,13 @@ async function createListElement(name, listPath, cardPaths, options = {}) { }); const destinationPath = finalTargetListPath + '/' + nextPrefix + cardFileName.slice(3); - // Remove visually immediately - if (evt.item && evt.item.parentNode) { - evt.item.parentNode.removeChild(evt.item); - } - - await window.board.moveCard(movedCardOriginalPath, destinationPath); - - // recordCardListMove only works if boardRoot matches. Derived boardRoot should match targetBoardPath here. - // But recordCardListMove in lib/archive.js requires card to be inside boardRoot. - // If we are moving BETWEEN boards, we probably should skip recording for now as it's not supported by the lib. - - // Switch to the target board + // 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; } From 4443039086fb0b854a95eb0757d3509b3dd3e26c Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 16:11:12 +0200 Subject: [PATCH 17/21] docs: document Unified View and drag-and-drop architecture in readme.md --- readme.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) 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. From d7715de44bc61b5db64a92644d7b054b4c11b973 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 17:38:52 +0200 Subject: [PATCH 18/21] Fix unified board drag-and-drop duplicate list bug and stuck card UI issue --- app/board/renderBoard.js | 37 +++ app/lists/createListElement.js | 415 +++++++++++++++++++-------------- 2 files changed, 282 insertions(+), 170 deletions(-) diff --git a/app/board/renderBoard.js b/app/board/renderBoard.js index 0e421c5..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,6 +346,10 @@ 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__'; diff --git a/app/lists/createListElement.js b/app/lists/createListElement.js index 9c066c9..1895456 100644 --- a/app/lists/createListElement.js +++ b/app/lists/createListElement.js @@ -1,4 +1,6 @@ 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'); @@ -92,215 +94,288 @@ async function createListElement(name, listPath, cardPaths, 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 targetIsUnified = evt && evt.to && evt.to.dataset ? evt.to.dataset.isUnified === 'true' : false; - const targetDisplayName = evt && evt.to && evt.to.dataset ? evt.to.dataset.displayName : ''; - - // 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); + 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 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; + // 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); - if (targetBoardPath) { - const UNIFIED_BOARD_PATH = '__unified__'; + // 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 (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; - } + 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); + } - // Move card to different board - try { - const targetLists = await window.board.listLists(targetBoardPath); - let targetListName = ''; - const sourceListDisplayName = evt && evt.from && evt.from.dataset ? evt.from.dataset.displayName : ''; - - for (const list of targetLists) { - if (list.displayName === sourceListDisplayName) { - targetListName = list.directoryName; - break; - } + 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; } + } + } - if (!targetListName) { - const nonArchive = targetLists.filter(l => !l.isArchive); - if (nonArchive.length > 0) targetListName = nonArchive[0].directoryName; + 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; } - 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); + // 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]; } - await window.board.moveCard(movedCardOriginalPath, destinationPath); + 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; } - } catch (e) { - console.error('Failed to move card to another board', e); - await renderBoard(); - return; } } - } - // 2. Resolve target list path (preserving board if in Unified view) - if (!evt || !evt.to) { - await renderBoard(); - return; - } - - let targetListPath = evt.to.dataset.path; - - if (targetIsUnified && movedCardOriginalPath) { - const openBoards = typeof getStoredOpenBoards === 'function' ? getStoredOpenBoards() : []; - const actualBoards = openBoards.filter(b => b !== '__unified__'); - let cardBoardRoot = actualBoards.find(root => movedCardOriginalPath.startsWith(root)); + // 2. Resolve target list path (preserving board if in Unified view) + if (!toEl) { + await renderBoard(); + return; + } - if (cardBoardRoot) { - try { - const boardLists = await window.board.listLists(cardBoardRoot); - let foundList = null; - for (const boardListName of boardLists) { - const boardListDisplayName = typeof getBoardListDisplayName === 'function' ? getBoardListDisplayName(boardListName) : boardListName; - if (boardListDisplayName === targetDisplayName) { - foundList = boardListName; - break; + 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 (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 (!targetListPath) { - await renderBoard(); - return; - } + if (!targetListPath) { + await renderBoard(); + return; + } - try { - const finalOrder = [...evt.to.querySelectorAll('.card')].map(card => - card.getAttribute('data-path') - ); + 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); + // 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; + // 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 fileNumber = String(fileCounter).padStart(3, '0'); - let adjustedFrom = filePath; + let fileCounter = 0; + let movedCardNextPath = ''; - // 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'); - } + 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; + } - 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'); + 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'); + } - await window.board.moveCard(adjustedFrom, adjustedTo); - - if (isTheMovedCard) { - movedCardNextPath = adjustedTo; - } + 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'); - fileCounter++; - } + await window.board.moveCard(adjustedFrom, adjustedTo); + + if (isTheMovedCard) { + movedCardNextPath = adjustedTo; + } - 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); - } + fileCounter++; + } - await renderBoard(); + 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); } })); }; From acb1ecf827643fae1c09e5f2380d1c07f06c5637 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 17:43:57 +0200 Subject: [PATCH 19/21] feat: add visual styling for unopened unified board tab --- app/board/boardTabs.js | 2 +- static/styles.css | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index f055850..6e1f687 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -533,7 +533,7 @@ function renderBoardTabs() { 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'); + addUnifiedTab.classList.add('board-tab', 'board-tab-add', 'board-tab-unified-unopened'); addUnifiedTab.setAttribute('role', 'presentation'); const addUnifiedButton = document.createElement('button'); diff --git a/static/styles.css b/static/styles.css index e02fe65..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; } From 400eb65f5374a4e275177bca0ed7fca084d50878 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 17:50:29 +0200 Subject: [PATCH 20/21] Implement Bulk Board Scanner and improve All Boards tab styling --- app/board/openBoard.js | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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; } + From bea9a83d26773339798d19ea6addfba080def7b4 Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Thu, 9 Apr 2026 17:53:16 +0200 Subject: [PATCH 21/21] Fix Unified View sync when closing board tabs --- app/board/boardTabs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/board/boardTabs.js b/app/board/boardTabs.js index 6e1f687..66a30f3 100644 --- a/app/board/boardTabs.js +++ b/app/board/boardTabs.js @@ -251,7 +251,7 @@ async function closeBoardTab(boardPath) { return; } - if (!closedActiveBoard) { + if (!closedActiveBoard && activeBoard !== UNIFIED_BOARD_PATH) { renderBoardTabs(); return; }