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