Files
Trailhead/electron/main.js
T
David 704e172da9 Rename project to trailhead-path-bookmarker
- npm name: trailhead-path-bookmarker (package.json + lockfile)
- electron-builder appId: com.dbeuttel.trailhead-path-bookmarker
- AppUserModelId in main.js matches the new appId
- README title updated; userData path now %APPDATA%\Trailhead Path Bookmarker

Icon generator now emits both sizes:
- assets/tray-icon.png (32x32) for the system tray
- assets/app-icon.png (256x256) for electron-builder's win.icon, which
  rejects PNGs smaller than 256
2026-04-29 10:27:25 -04:00

766 lines
26 KiB
JavaScript

const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const launchers = require('./launchers');
// 60% of the original 360px slot, rounded to a clean number.
const POPUP_COLUMN_WIDTH = 216;
const POPUP_MIN_WIDTH = POPUP_COLUMN_WIDTH;
const POPUP_MIN_HEIGHT = 180;
const POPUP_MAX_HEIGHT = 600;
const POPUP_DEFAULT_HEIGHT = 360;
const DEV_URL = 'http://localhost:5174';
const isDev = !app.isPackaged;
let tray = null;
let popupWindow = null;
let configCache = null;
let pinned = false;
let saveBoundsDebounce = null;
function configPath() {
return path.join(app.getPath('userData'), 'config.json');
}
function readConfig() {
if (configCache) return configCache;
try {
configCache = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
} catch {
configCache = {};
}
return configCache;
}
function writeConfig(patch) {
const next = { ...readConfig(), ...patch };
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2));
configCache = next;
return next;
}
function getIconPath() {
return path.join(__dirname, '..', 'assets', 'tray-icon.png');
}
function getTabs() {
const cfg = readConfig();
return Array.isArray(cfg.tabs) ? cfg.tabs.filter((t) => t && t.id && t.name) : [];
}
function getColumns() {
const cfg = readConfig();
return Array.isArray(cfg.columns) ? cfg.columns.filter((c) => c && c.id && c.name) : [];
}
function getBookmarks() {
const cfg = readConfig();
return Array.isArray(cfg.bookmarks) ? cfg.bookmarks.filter((b) => b && b.id && b.alias && b.path) : [];
}
function getActiveTabId() {
const cfg = readConfig();
const tabs = getTabs();
if (!tabs.length) return null;
const stored = cfg.activeTabId;
if (stored && tabs.find((t) => t.id === stored)) return stored;
return tabs[0].id;
}
// Per-button visibility for the row action bar. Defaults to true so existing
// installs see no behavior change.
function getButtonVisibility() {
const cfg = readConfig();
const v = cfg.buttonVisibility || {};
return {
claude: v.claude !== false,
terminal: v.terminal !== false,
redeploy: v.redeploy !== false,
};
}
// Default-on autostart. Honor an explicit `false` so users can opt out.
function getAutoStart() {
const cfg = readConfig();
return cfg.autoStart !== false;
}
// Sync the OS login-item state to config. Skipped in dev so the running
// devtools/electron build never gets registered against Windows logon.
function applyAutoStart() {
if (process.platform !== 'win32') return;
if (isDev) return;
try {
app.setLoginItemSettings({
openAtLogin: getAutoStart(),
// Hide the popup window on auto-launch — the app is tray-resident.
args: ['--hidden'],
});
} catch {
// Setting login items can fail under restricted policies; ignore so the
// app still launches even if startup registration is blocked.
}
}
// One-time migration when loading older configs:
// 1. Ensure `columns` and `tabs` are arrays.
// 2. If bookmarks exist but no columns, create a default "Bookmarks" column
// and assign every existing bookmark to it.
// 3. Reassign any orphan bookmarks (referencing a missing columnId) to the
// first column.
// 4. If columns exist but no tabs, create a default "Main" tab and assign
// every existing column to it.
// 5. Reassign any orphan columns (referencing a missing tabId) to the first
// tab.
function migrateConfigIfNeeded() {
const cfg = readConfig();
let mutated = false;
let columns = Array.isArray(cfg.columns) ? cfg.columns.slice() : [];
let bookmarks = Array.isArray(cfg.bookmarks) ? cfg.bookmarks.slice() : [];
let tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
let activeTabId = cfg.activeTabId;
if (!Array.isArray(cfg.columns)) mutated = true;
if (!Array.isArray(cfg.tabs)) mutated = true;
if (bookmarks.length > 0 && columns.length === 0) {
const defaultCol = { id: crypto.randomUUID(), name: 'Bookmarks' };
columns = [defaultCol];
bookmarks = bookmarks.map((b) => (b && !b.columnId ? { ...b, columnId: defaultCol.id } : b));
mutated = true;
} else if (bookmarks.length > 0 && columns.length > 0) {
const validIds = new Set(columns.map((c) => c.id));
const firstId = columns[0].id;
bookmarks = bookmarks.map((b) => {
if (!b) return b;
if (!b.columnId || !validIds.has(b.columnId)) {
mutated = true;
return { ...b, columnId: firstId };
}
return b;
});
}
if (columns.length > 0 && tabs.length === 0) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabs = [defaultTab];
columns = columns.map((c) => (c && !c.tabId ? { ...c, tabId: defaultTab.id } : c));
activeTabId = defaultTab.id;
mutated = true;
} else if (columns.length > 0 && tabs.length > 0) {
const validTabIds = new Set(tabs.map((t) => t.id));
const firstTabId = tabs[0].id;
columns = columns.map((c) => {
if (!c) return c;
if (!c.tabId || !validTabIds.has(c.tabId)) {
mutated = true;
return { ...c, tabId: firstTabId };
}
return c;
});
if (!activeTabId || !validTabIds.has(activeTabId)) {
activeTabId = firstTabId;
mutated = true;
}
}
if (mutated) writeConfig({ columns, bookmarks, tabs, activeTabId });
}
function getCurrentHeight() {
const cfg = readConfig();
const stored = Number(cfg.popupHeight);
if (Number.isFinite(stored) && stored > 0) {
return Math.max(POPUP_MIN_HEIGHT, Math.min(POPUP_MAX_HEIGHT, stored));
}
return POPUP_DEFAULT_HEIGHT;
}
// Cap the popup width at the screen's work area minus a small margin so it
// never overflows. With many columns the user gets internal horizontal
// scroll instead of the window pushing off-screen.
function getMaxWidth() {
try {
return Math.max(POPUP_MIN_WIDTH, screen.getPrimaryDisplay().workArea.width - 8);
} catch {
return POPUP_MIN_WIDTH * 6;
}
}
function getCurrentWidth() {
const cfg = readConfig();
const stored = Number(cfg.popupWidth);
const max = getMaxWidth();
if (Number.isFinite(stored) && stored > 0) {
return Math.max(POPUP_MIN_WIDTH, Math.min(max, stored));
}
return POPUP_COLUMN_WIDTH;
}
function createPopupWindow() {
popupWindow = new BrowserWindow({
width: getCurrentWidth(),
height: getCurrentHeight(),
show: false,
frame: false,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
fullscreenable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
if (isDev) {
popupWindow.loadURL(DEV_URL);
} else {
popupWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
popupWindow.on('blur', () => {
if (pinned) return;
if (!popupWindow.webContents.isDevToolsOpened()) popupWindow.hide();
});
popupWindow.on('close', (e) => {
if (!app.isQuitting) {
e.preventDefault();
popupWindow.hide();
}
});
// Persist drag position only when pinned. 300ms debounce because Electron
// fires 'moved' on every pixel during a drag — writing config that often
// would thrash the disk and produce a stale position if the drag is
// interrupted mid-flight.
popupWindow.on('moved', () => {
if (!pinned) return;
if (saveBoundsDebounce) clearTimeout(saveBoundsDebounce);
saveBoundsDebounce = setTimeout(() => {
const b = popupWindow.getBounds();
writeConfig({ pinnedBounds: { x: b.x, y: b.y } });
}, 300);
});
}
function positionPopup() {
const trayBounds = tray.getBounds();
const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y });
const workArea = display.workArea;
const width = getCurrentWidth();
const height = getCurrentHeight();
let x = Math.round(trayBounds.x + trayBounds.width / 2 - width / 2);
let y = Math.round(trayBounds.y - height - 8);
x = Math.max(workArea.x + 4, Math.min(x, workArea.x + workArea.width - width - 4));
if (y < workArea.y + 4) y = trayBounds.y + trayBounds.height + 4;
popupWindow.setBounds({ x, y, width, height });
}
function togglePopup() {
if (!popupWindow) return;
if (popupWindow.isVisible()) {
popupWindow.hide();
} else {
if (pinned) {
const saved = readConfig().pinnedBounds;
const width = getCurrentWidth();
const height = getCurrentHeight();
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
popupWindow.setBounds({ x: saved.x, y: saved.y, width, height });
} else {
positionPopup();
}
} else {
positionPopup();
}
popupWindow.show();
popupWindow.focus();
// Tell the renderer to re-inspect folders. Rows mount once and stay
// mounted while the window is hidden, so without this the redeploy/sln
// detection won't notice files added between popup opens.
if (popupWindow.webContents) popupWindow.webContents.send('popup-shown');
}
}
function truncatePath(p) {
if (!p) return '';
if (p.length <= 38) return p;
return p.slice(0, 18) + '…' + p.slice(-18);
}
function broadcastConfig() {
if (popupWindow && !popupWindow.isDestroyed()) {
popupWindow.webContents.send('config-updated', {
tabs: getTabs(),
columns: getColumns(),
bookmarks: getBookmarks(),
activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(),
});
}
}
function buildContextMenu() {
const bookmarks = getBookmarks();
const items = [
{ label: 'Open', click: togglePopup },
{ type: 'separator' },
];
if (bookmarks.length === 0) {
items.push({ label: 'No bookmarks yet', enabled: false });
} else {
for (const b of bookmarks) {
const inspected = launchers.inspectFolder(b.path);
const submenu = [
{ label: 'Open in Explorer', click: () => launchers.openInExplorer(b.path) },
{
label: 'Open Terminal here',
click: () => launchers.openTerminal({ targetPath: b.path, alias: b.alias, color: b.color }),
},
];
// Network shares hide Claude / VS — neither is meaningful there.
if (!inspected.isNetwork) {
submenu.push({
label: 'Open Claude here',
click: () => launchers.openClaude({ targetPath: b.path, alias: b.alias, color: b.color }),
});
submenu.push({
label: inspected.slnPath ? 'Open in Visual Studio' : 'Open in Visual Studio (no .sln)',
enabled: !!inspected.slnPath,
click: () => launchers.openVisualStudio(b.path),
});
}
if (inspected.redeployPath && !b.hideDeploy) {
submenu.push({
label: 'Run 1ReDeploy.bat',
click: () => launchers.runRedeploy(b.path),
});
}
submenu.push({ type: 'separator' });
submenu.push({ label: 'Copy path', click: () => launchers.copyPath(b.path) });
items.push({
label: `${b.alias}${truncatePath(b.path)}`,
submenu,
});
}
}
items.push({ type: 'separator' });
items.push({
label: 'Browse for folder…',
click: async () => {
const picked = await launchers.pickFolder();
if (!picked) return;
const alias = lastSegment(picked);
addBookmarkInternal({ alias, path: picked });
},
});
items.push({
label: 'Manage…',
click: () => {
if (!popupWindow.isVisible()) togglePopup();
},
});
items.push({ type: 'separator' });
items.push({
label: 'Quit',
click: () => {
app.isQuitting = true;
app.quit();
},
});
return Menu.buildFromTemplate(items);
}
function rebuildContextMenu() {
if (tray) tray.setContextMenu(buildContextMenu());
}
function lastSegment(p) {
if (!p) return '';
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || p;
}
// Internal helper used by both the tray "Browse for folder…" item and the
// add-bookmark IPC handler so they share creation logic.
function addBookmarkInternal({ id, alias, path: pathValue, color, columnId, hideDeploy }) {
if (!alias || !pathValue) return getBookmarks();
const cleanColor = (typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)) ? color : null;
// Treat undefined as "don't touch" (so add-without-flag preserves prior value
// on edit). Only true / false explicitly modify the field.
const cleanHideDeploy = typeof hideDeploy === 'boolean' ? hideDeploy : undefined;
const cfg = readConfig();
const cols = Array.isArray(cfg.columns) ? cfg.columns : [];
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs : [];
let firstColId = cols.length > 0 ? cols[0].id : null;
// Auto-create a "Main" tab + "Bookmarks" column if none exist yet so new
// entries always have somewhere to live.
let columnsToWrite = null;
let tabsToWrite = null;
let activeTabIdToWrite = null;
let firstTabId = tabs.length > 0 ? tabs[0].id : null;
if (!firstTabId) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabsToWrite = [defaultTab];
firstTabId = defaultTab.id;
activeTabIdToWrite = defaultTab.id;
}
if (!firstColId) {
const defaultCol = { id: crypto.randomUUID(), name: 'Bookmarks', tabId: firstTabId };
columnsToWrite = [defaultCol];
firstColId = defaultCol.id;
}
const finalColId = columnId && cols.find((c) => c.id === columnId) ? columnId : firstColId;
const list = Array.isArray(cfg.bookmarks) ? cfg.bookmarks.slice() : [];
if (id) {
const idx = list.findIndex((b) => b && b.id === id);
if (idx >= 0) {
const merged = { ...list[idx], alias, path: pathValue };
if (cleanColor) merged.color = cleanColor; else delete merged.color;
// Only overwrite columnId when the caller explicitly supplied one.
if (columnId !== undefined) merged.columnId = finalColId;
if (cleanHideDeploy === true) merged.hideDeploy = true;
else if (cleanHideDeploy === false) delete merged.hideDeploy;
list[idx] = merged;
} else {
const entry = {
id, alias, path: pathValue,
createdAt: new Date().toISOString(),
columnId: finalColId,
};
if (cleanColor) entry.color = cleanColor;
if (cleanHideDeploy === true) entry.hideDeploy = true;
list.push(entry);
}
} else {
const entry = {
id: crypto.randomUUID(),
alias,
path: pathValue,
createdAt: new Date().toISOString(),
columnId: finalColId,
};
if (cleanColor) entry.color = cleanColor;
if (cleanHideDeploy === true) entry.hideDeploy = true;
list.push(entry);
}
const patch = { bookmarks: list };
if (columnsToWrite) patch.columns = columnsToWrite;
if (tabsToWrite) patch.tabs = tabsToWrite;
if (activeTabIdToWrite) patch.activeTabId = activeTabIdToWrite;
writeConfig(patch);
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
}
function createTray() {
const image = nativeImage.createFromPath(getIconPath());
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image);
tray.setToolTip('Trailhead Path Bookmarker');
tray.setContextMenu(buildContextMenu());
tray.on('click', togglePopup);
}
// ---- IPC handlers ----
ipcMain.handle('get-config', () => {
const cfg = readConfig();
return {
...cfg,
tabs: getTabs(),
columns: getColumns(),
bookmarks: getBookmarks(),
activeTabId: getActiveTabId(),
buttonVisibility: getButtonVisibility(),
autoStart: getAutoStart(),
};
});
ipcMain.handle('set-auto-start', (_event, value) => {
const next = !!value;
writeConfig({ autoStart: next });
applyAutoStart();
broadcastConfig();
return getAutoStart();
});
ipcMain.handle('set-button-visibility', (_event, payload) => {
if (!payload || typeof payload !== 'object') return getButtonVisibility();
const current = getButtonVisibility();
const next = {
claude: typeof payload.claude === 'boolean' ? payload.claude : current.claude,
terminal: typeof payload.terminal === 'boolean' ? payload.terminal : current.terminal,
redeploy: typeof payload.redeploy === 'boolean' ? payload.redeploy : current.redeploy,
};
writeConfig({ buttonVisibility: next });
broadcastConfig();
return getButtonVisibility();
});
ipcMain.handle('set-config', (_event, patch) => {
return writeConfig(patch || {});
});
ipcMain.handle('add-bookmark', (_event, payload) => {
if (!payload) return getBookmarks();
return addBookmarkInternal(payload);
});
ipcMain.handle('remove-bookmark', (_event, id) => {
const cfg = readConfig();
const list = (cfg.bookmarks || []).filter((b) => b && b.id !== id);
writeConfig({ bookmarks: list });
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
});
ipcMain.handle('reorder-bookmarks', (_event, ids) => {
if (!Array.isArray(ids)) return getBookmarks();
const current = getBookmarks();
const byId = new Map(current.map((b) => [b.id, b]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const b of current) if (!ids.includes(b.id)) reordered.push(b);
writeConfig({ bookmarks: reordered });
rebuildContextMenu();
broadcastConfig();
return getBookmarks();
});
ipcMain.handle('add-column', (_event, payload) => {
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!name) return getColumns();
const cfg = readConfig();
const cols = Array.isArray(cfg.columns) ? cfg.columns.slice() : [];
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
const patch = {};
// Auto-create a tab if none exists yet so the new column has a parent.
let tabId = payload && typeof payload.tabId === 'string' ? payload.tabId : null;
if (!tabId || !tabs.find((t) => t.id === tabId)) tabId = getActiveTabId();
if (!tabId) {
const defaultTab = { id: crypto.randomUUID(), name: 'Main' };
tabs.push(defaultTab);
tabId = defaultTab.id;
patch.tabs = tabs;
patch.activeTabId = defaultTab.id;
}
cols.push({ id: crypto.randomUUID(), name, tabId });
patch.columns = cols;
writeConfig(patch);
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('rename-column', (_event, payload) => {
const id = payload && payload.id;
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!id || !name) return getColumns();
const cfg = readConfig();
const cols = (cfg.columns || []).map((c) => (c.id === id ? { ...c, name } : c));
writeConfig({ columns: cols });
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('remove-column', (_event, payload) => {
const id = payload && payload.id;
const reassignTo = payload && payload.reassignTo;
if (!id) return { columns: getColumns(), bookmarks: getBookmarks() };
const cfg = readConfig();
const cols = (cfg.columns || []).filter((c) => c.id !== id);
let bookmarks = cfg.bookmarks || [];
if (reassignTo && cols.find((c) => c.id === reassignTo)) {
bookmarks = bookmarks.map((b) => (b && b.columnId === id ? { ...b, columnId: reassignTo } : b));
} else {
bookmarks = bookmarks.filter((b) => b && b.columnId !== id);
}
writeConfig({ columns: cols, bookmarks });
rebuildContextMenu();
broadcastConfig();
return { columns: getColumns(), bookmarks: getBookmarks() };
});
ipcMain.handle('reorder-columns', (_event, ids) => {
if (!Array.isArray(ids)) return getColumns();
const cfg = readConfig();
const byId = new Map((cfg.columns || []).map((c) => [c.id, c]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const c of (cfg.columns || [])) if (!ids.includes(c.id)) reordered.push(c);
writeConfig({ columns: reordered });
rebuildContextMenu();
broadcastConfig();
return getColumns();
});
ipcMain.handle('move-column-to-tab', (_event, payload) => {
const columnId = payload && payload.columnId;
const tabId = payload && payload.tabId;
if (!columnId || !tabId) return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs : [];
if (!tabs.find((t) => t.id === tabId)) {
return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
}
const cols = (cfg.columns || []).map((c) => (c && c.id === columnId ? { ...c, tabId } : c));
writeConfig({ columns: cols });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), columns: getColumns(), activeTabId: getActiveTabId() };
});
ipcMain.handle('add-tab', (_event, payload) => {
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!name) return { tabs: getTabs(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const tabs = Array.isArray(cfg.tabs) ? cfg.tabs.slice() : [];
const newTab = { id: crypto.randomUUID(), name };
tabs.push(newTab);
// Switch to the freshly-created tab so the user sees their new (empty) workspace.
writeConfig({ tabs, activeTabId: newTab.id });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), activeTabId: getActiveTabId() };
});
ipcMain.handle('rename-tab', (_event, payload) => {
const id = payload && payload.id;
const name = payload && typeof payload.name === 'string' ? payload.name.trim() : '';
if (!id || !name) return getTabs();
const cfg = readConfig();
const tabs = (cfg.tabs || []).map((t) => (t.id === id ? { ...t, name } : t));
writeConfig({ tabs });
rebuildContextMenu();
broadcastConfig();
return getTabs();
});
ipcMain.handle('remove-tab', (_event, payload) => {
const id = payload && payload.id;
const reassignTo = payload && payload.reassignTo;
if (!id) return { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), activeTabId: getActiveTabId() };
const cfg = readConfig();
const remainingTabs = (cfg.tabs || []).filter((t) => t.id !== id);
let columns = cfg.columns || [];
let bookmarks = cfg.bookmarks || [];
if (reassignTo && remainingTabs.find((t) => t.id === reassignTo)) {
columns = columns.map((c) => (c && c.tabId === id ? { ...c, tabId: reassignTo } : c));
} else {
// Cascade: drop columns assigned to this tab, then drop bookmarks orphaned by that.
const droppedColIds = new Set(columns.filter((c) => c && c.tabId === id).map((c) => c.id));
columns = columns.filter((c) => c && c.tabId !== id);
bookmarks = bookmarks.filter((b) => b && !droppedColIds.has(b.columnId));
}
let activeTabId = cfg.activeTabId === id
? (remainingTabs[0] && remainingTabs[0].id) || null
: cfg.activeTabId;
writeConfig({ tabs: remainingTabs, columns, bookmarks, activeTabId });
rebuildContextMenu();
broadcastConfig();
return { tabs: getTabs(), columns: getColumns(), bookmarks: getBookmarks(), activeTabId: getActiveTabId() };
});
ipcMain.handle('reorder-tabs', (_event, ids) => {
if (!Array.isArray(ids)) return getTabs();
const cfg = readConfig();
const byId = new Map((cfg.tabs || []).map((t) => [t.id, t]));
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
for (const t of (cfg.tabs || [])) if (!ids.includes(t.id)) reordered.push(t);
writeConfig({ tabs: reordered });
rebuildContextMenu();
broadcastConfig();
return getTabs();
});
ipcMain.handle('set-active-tab', (_event, id) => {
const tabs = getTabs();
if (!id || !tabs.find((t) => t.id === id)) return getActiveTabId();
writeConfig({ activeTabId: id });
return getActiveTabId();
});
ipcMain.handle('pick-folder', () => launchers.pickFolder());
ipcMain.handle('inspect-folder', (_event, p) => launchers.inspectFolder(p));
ipcMain.handle('open-in-explorer', (_event, p) => launchers.openInExplorer(p));
ipcMain.handle('open-terminal', (_event, payload) => {
if (typeof payload === 'string') return launchers.openTerminal({ targetPath: payload });
return launchers.openTerminal(payload || {});
});
ipcMain.handle('open-claude', (_event, payload) => {
if (typeof payload === 'string') return launchers.openClaude({ targetPath: payload });
return launchers.openClaude(payload || {});
});
ipcMain.handle('open-visual-studio', (_event, p) => launchers.openVisualStudio(p));
ipcMain.handle('run-redeploy', (_event, p) => launchers.runRedeploy(p));
ipcMain.handle('copy-path', (_event, text) => launchers.copyPath(text));
ipcMain.handle('is-claude-available', () => launchers.isClaudeAvailable());
ipcMain.handle('set-pinned', (_event, value) => {
pinned = !!value;
writeConfig({ pinned });
if (pinned && popupWindow && popupWindow.isVisible()) {
popupWindow.focus();
}
return pinned;
});
ipcMain.handle('set-popup-height', (_event, h) => {
if (!popupWindow || popupWindow.isDestroyed()) return null;
const target = Math.max(POPUP_MIN_HEIGHT, Math.min(POPUP_MAX_HEIGHT, Math.round(Number(h) || 0)));
if (!Number.isFinite(target) || target <= 0) return null;
const bounds = popupWindow.getBounds();
popupWindow.setBounds({ x: bounds.x, y: bounds.y, width: bounds.width, height: target });
writeConfig({ popupHeight: target });
return target;
});
ipcMain.handle('set-popup-width', (_event, w) => {
if (!popupWindow || popupWindow.isDestroyed()) return null;
const bounds = popupWindow.getBounds();
const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y });
const workArea = display.workArea;
const maxWidth = Math.max(POPUP_MIN_WIDTH, workArea.width - 8);
const target = Math.max(POPUP_MIN_WIDTH, Math.min(maxWidth, Math.round(Number(w) || 0)));
if (!Number.isFinite(target) || target <= 0) return null;
// Re-clamp x against the work area so a wider popup doesn't fall off-screen.
let x = bounds.x;
if (x + target > workArea.x + workArea.width - 4) x = workArea.x + workArea.width - target - 4;
if (x < workArea.x + 4) x = workArea.x + 4;
popupWindow.setBounds({ x, y: bounds.y, width: target, height: bounds.height });
writeConfig({ popupWidth: target });
return target;
});
app.whenReady().then(() => {
if (process.platform === 'win32') {
app.setAppUserModelId('com.dbeuttel.trailhead-path-bookmarker');
}
migrateConfigIfNeeded();
pinned = !!readConfig().pinned;
applyAutoStart();
createTray();
createPopupWindow();
});
app.on('window-all-closed', (e) => e.preventDefault());
app.on('before-quit', () => {
app.isQuitting = true;
});