From 51c6494c2ced18431773f3c1efb71eed3ef11959 Mon Sep 17 00:00:00 2001 From: AropJoe Date: Fri, 9 Oct 2026 23:24:04 +0100 Subject: [PATCH 1/2] Open shows one document with notes; add to any notebook; open at login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Open shows the document in the workspace on its own (layout=one), with Open in app for the program it belongs to. Closing either of two documents leaves the other full width; One, Side by side and Stacked switch between them. - Selecting text offers Add to , and its menu lists every notebook you can add to or starts a new one named after the document. - Settings › This computer › Open at login starts Duct in the background when you sign in (login items on macOS and Windows, XDG autostart on Linux). - Long file names in All documents end in an ellipsis instead of running over the other columns; the tooltip shows the full name and path. --- assets/ui/app.css | 6 +- assets/ui/app.js | 11 ++-- electron/main.cjs | 71 +++++++++++++---------- electron/workspace-preload.cjs | 7 +++ src/workspace.ts | 101 ++++++++++++++++++++++++++------- 5 files changed, 137 insertions(+), 59 deletions(-) create mode 100644 electron/workspace-preload.cjs diff --git a/assets/ui/app.css b/assets/ui/app.css index 8d8c196..3002a5e 100644 --- a/assets/ui/app.css +++ b/assets/ui/app.css @@ -211,8 +211,8 @@ body.no-mascot .mascot-slot { display: none; } .seg-item.active { background: var(--s3); color: var(--text); } .doc-row { display: grid; grid-template-columns: 38px minmax(0, 2fr) minmax(0, 1.4fr) 110px auto; gap: 12px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--border); font-size: 13px; } .doc-row.head { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-bottom-color: var(--border2); } -.doc-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } -.doc-row .folder { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--muted); } +.doc-row .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } +.doc-row .folder { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--muted); } .doc-row .err { grid-column: 2 / -1; font-size: 12px; color: var(--muted); margin-top: -4px; } .doc-row .row-actions { display: flex; gap: 6px; justify-content: flex-end; } .status { font-family: var(--mono); font-size: 11px; border-radius: 5px; padding: 2px 7px; border: 1px solid; white-space: nowrap; justify-self: start; } @@ -300,7 +300,7 @@ dialog::backdrop { background: rgba(0,0,0,.55); } .tag-editor { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 16px; } .tag-editor input { background: var(--bg); color: var(--text); border: 1px solid var(--border2); border-radius: 99px; padding: 2px 10px; font: inherit; font-size: 12px; width: 150px; outline: none; } .tag-editor input:focus { border-color: var(--lime); } -.doc-row .name-cell { min-width: 0; } +.doc-row .name-cell { display: block; min-width: 0; overflow: hidden; } .doc-row .name-cell .tags { margin-top: 2px; } .cite-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0 0; } .collect-dialog { width: min(720px, calc(100vw - 32px)); max-height: min(80vh, 760px); } diff --git a/assets/ui/app.js b/assets/ui/app.js index 338e2b7..088e1ce 100644 --- a/assets/ui/app.js +++ b/assets/ui/app.js @@ -218,6 +218,7 @@ const FEATURE_GROUPS = [ ] const KIND_LABELS = { document: 'Documents (PDF, Word, Pages, Markdown, HTML…)', spreadsheet: 'Spreadsheets', presentation: 'Presentations', ebook: 'E-books', email: 'Email', text: 'Text, CSV, JSON and subtitles', code: 'Source code', image: 'Images and SVG', archive: 'ZIP archives' } const DESKTOP_PREFS = [ + ['openAtLogin', 'Open at login', 'Start Duct when you sign in to this computer, so quick search and watched folders are always ready.'], ['island', 'Notch companion', 'Duct at the top of the screen: progress, quick search and a drop zone.'], ['sounds', 'Sounds', 'Short sounds when a job finishes or needs you.'], ['shortcut', 'Quick search shortcut', 'Search from any app, without switching to Duct.'], @@ -905,11 +906,9 @@ async function openDocument(path, page, highlightTerms = []) { if (!(await desktop.openDocument(path, page, highlightTerms))) toast("Couldn't open " + fileName(path), true) return } - if (/\.pdf$/i.test(path)) { - window.open('/viewer?path=' + encodeURIComponent(path) + '&page=' + (page || 1) + '&terms=' + encodeURIComponent(JSON.stringify(highlightTerms)), '_blank', 'noopener') - return - } - window.open('/api/file/' + encodeURIComponent(fileName(path)) + '?path=' + encodeURIComponent(path), '_blank', 'noopener') + // The workspace with this one document: pages as they look, and text you can add to any notebook. + const q = new URLSearchParams({ left: path, layout: 'one', lpage: String(page || 1), lterms: JSON.stringify(highlightTerms || []) }) + window.open('/workspace?' + q, '_blank', 'noopener') } const openResult = r => r && openDocument(r.chunk.documentPath, r.chunk.page, terms(r)) @@ -1029,7 +1028,7 @@ function renderDocuments() { if (desktop && desktop.revealDocument && !link) actions.push('') if (isAdmin()) actions.push('') return '
' + badge(d.format) + - '' + esc(d.displayName || fileName(d.path)) + '' + + '' + esc(d.displayName || fileName(d.path)) + '' + ((d.tags || []).length ? '' + d.tags.map(t => '' + esc(t) + '').join('') + '' : '') + '' + '' + esc(link ? d.path : d.source === 'library' ? 'Duct Library' : folderOf(d.path)) + '' + statusOf(d) + '' + actions.join('') + '' + diff --git a/electron/main.cjs b/electron/main.cjs index 3f5ae7a..652d8f2 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -112,7 +112,32 @@ function shortcutStatus() { } // Desktop-only switches shown in the page's Settings > Features (the rest live in the index's settings). +// Open at login: started in the background (no window), so quick search, the island and watched folders are ready. +// macOS and Windows keep this in the system's login items; Linux uses an XDG autostart entry. +const HIDDEN_FLAG = '--hidden' +const autostartFile = () => path.join(app.getPath('home'), '.config', 'autostart', 'duct.desktop') +function openAtLoginEnabled() { + if (process.platform === 'linux') return fs.existsSync(autostartFile()) + return app.getLoginItemSettings({ args: [HIDDEN_FLAG] }).openAtLogin +} +function setOpenAtLogin(on) { + if (process.platform === 'linux') { + if (!on) { fs.rmSync(autostartFile(), { force: true }); return } + const exec = process.env.APPIMAGE || process.execPath + fs.mkdirSync(path.dirname(autostartFile()), { recursive: true }) + fs.writeFileSync(autostartFile(), `[Desktop Entry]\nType=Application\nName=Duct\nExec="${exec}" ${HIDDEN_FLAG}\nX-GNOME-Autostart-enabled=true\n`) + return + } + app.setLoginItemSettings({ openAtLogin: on, openAsHidden: true, args: [HIDDEN_FLAG] }) +} +/** + * Started by the system at login, rather than by someone opening Duct: stay in the background. Windows and Linux pass + * --hidden; recent macOS doesn't say, so there the window opens, and closing it leaves Duct in the menu bar. + */ +const startedAtLogin = () => process.argv.includes(HIDDEN_FLAG) || (process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin) + const DESKTOP_PREFS = { + openAtLogin: { get: openAtLoginEnabled, set: on => setOpenAtLogin(on) }, island: { get: islandEnabled, set: on => setIslandEnabled(on) }, sounds: { get: soundsEnabled, set: on => setSoundsEnabled(on) }, shortcut: { get: shortcutEnabled, set: on => setShortcutEnabled(on) }, @@ -314,7 +339,7 @@ async function addFilesToLibrary(filePaths) { return { added, duplicates, failed } } -function createWindow() { +function createWindow({ background = false } = {}) { mainWindow = new BrowserWindow({ width: 1280, height: 860, @@ -343,7 +368,7 @@ function createWindow() { mainWindow.loadURL(serverUrl) mainWindow.once('ready-to-show', () => { - mainWindow.show() + if (!background) mainWindow.show() }) mainWindow.on('closed', () => { @@ -555,39 +580,18 @@ ipcMain.handle('duct:watchDirectory', async () => { // Only documents in the index can be opened or revealed; the page can't name arbitrary paths. // PDFs open in Duct's viewer at the page, with the matched words highlighted; other files use their default app. +// "Open" shows the document in Duct's workspace on its own, where text can be selected and added to any notebook; +// "Open in app" there hands it to the app it belongs to. async function openDocument(filePath, page, terms) { - const doc = indexedFile(filePath) - if (!doc) return false - if (path.extname(doc.path).toLowerCase() === '.pdf') { - const safeTerms = Array.isArray(terms) ? terms.filter(t => typeof t === 'string').slice(0, 12) : [] - const pageNumber = Number.isInteger(page) && page > 0 ? page : 1 - const viewer = new BrowserWindow({ - width: 1000, - height: 1100, - title: doc.displayName || path.basename(doc.path), - backgroundColor: '#0C0C0B', - icon: path.join(__dirname, 'icon.png'), - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, - }) - viewer.webContents.on('will-navigate', (event, url) => { if (!url.startsWith(serverUrl)) event.preventDefault() }) - viewer.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - await viewer.loadURL(`${serverUrl}/viewer?path=${encodeURIComponent(doc.path)}&page=${pageNumber}&terms=${encodeURIComponent(JSON.stringify(safeTerms))}`) - return true - } + if (!indexedFile(filePath)) return false telemetry?.record('opens') - return (await shell.openPath(doc.path)) === '' -} - -function indexedFile(filePath) { - const doc = typeof filePath === 'string' ? duct.getDocument(filePath) : undefined - if (!doc || doc.source === 'url' || !fs.existsSync(doc.path)) return null - return doc + return openWorkspace(filePath, '', page, terms, '', 'one') } ipcMain.handle('duct:openDocument', (_event, filePath, page, terms) => openDocument(filePath, page, terms)) // The workspace: two documents side by side with a notebook. Any indexed document can be shown, not only PDFs. -function openWorkspace(left, right, page, terms, notebook) { +function openWorkspace(left, right, page, terms, notebook, layout) { const known = p => (typeof p === 'string' && duct.getDocument(p) ? duct.getDocument(p).path : '') const query = new URLSearchParams() if (known(left)) query.set('left', known(left)) @@ -595,13 +599,14 @@ function openWorkspace(left, right, page, terms, notebook) { if (Number.isInteger(page) && page > 0) query.set('lpage', String(page)) if (Array.isArray(terms) && terms.length) query.set('lterms', JSON.stringify(terms.filter(t => typeof t === 'string').slice(0, 12))) if (typeof notebook === 'string' && /^[\w-]{1,64}$/.test(notebook)) query.set('notebook', notebook) + if (layout === 'one') query.set('layout', 'one') const win = new BrowserWindow({ width: 1500, height: 950, title: 'Duct Workspace', backgroundColor: '#0C0C0B', icon: path.join(__dirname, 'icon.png'), - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: path.join(__dirname, 'workspace-preload.cjs') }, }) win.webContents.on('will-navigate', (event, url) => { if (!url.startsWith(serverUrl)) event.preventDefault() }) win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) @@ -611,6 +616,12 @@ function openWorkspace(left, right, page, terms, notebook) { ipcMain.handle('duct:openWorkspace', (_event, left, right, page, terms, notebook) => openWorkspace(left, right, page, terms, notebook)) +ipcMain.handle('duct:openInApp', async (_event, filePath) => { + const doc = indexedFile(filePath) + if (!doc) return false + return (await shell.openPath(doc.path)) === '' +}) + ipcMain.handle('duct:revealDocument', (_event, filePath) => { const doc = indexedFile(filePath) if (!doc) return false @@ -647,7 +658,7 @@ app.whenReady().then(async () => { console.log(` Server started at ${url}`) createAppMenu() - createWindow() + createWindow({ background: startedAtLogin() }) createTray() if (islandEnabled()) startIsland() registerShortcut() diff --git a/electron/workspace-preload.cjs b/electron/workspace-preload.cjs new file mode 100644 index 0000000..0b65388 --- /dev/null +++ b/electron/workspace-preload.cjs @@ -0,0 +1,7 @@ +// The workspace window's only bridge to the desktop app: open the document it shows in the app it belongs to. +// The main process checks the path is an indexed document before opening anything. +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('ductWorkspace', { + openInApp: path => ipcRenderer.invoke('duct:openInApp', path), +}) diff --git a/src/workspace.ts b/src/workspace.ts index 96ca099..7a5dfd4 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,6 +1,7 @@ // Duct's workspace: two documents side by side, with a notebook beside them. // Opened as /workspace?left=&right=¬ebook=. Optional: lpage, lterms, rpage, rterms (terms is a JSON array -// of words to highlight in a PDF). `page` and `terms` are aliases for the left pane. +// of words to highlight in a PDF), and layout=one|cols|rows. `page` and `terms` are aliases for the left pane. "Open" in +// the app shows one document (layout=one); closing either pane of two leaves the other on its own. // // PDFs are drawn with pdf.js (served locally, see the /vendor/pdfjs routes in server.ts). Every other format is shown as the // text Duct extracted, in sections (a page, slide, sheet or chapter each), so text can be selected in any of them. Selecting @@ -127,7 +128,11 @@ main { flex: 1; min-height: 0; display: flex; } .note .by { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: -4px; } .note textarea[readonly] { border-color: transparent; background: transparent; padding-left: 0; resize: none; min-height: 0; } .add-pop { position: fixed; z-index: 50; display: none; } -.add-pop.show { display: block; } +.add-pop.show { display: flex; gap: 1px; } +.add-pop .go { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px 0 0 6px; } +.add-pop .more { border-radius: 0 6px 6px 0; padding: 4px 7px; } +.add-pop .menu-pop { left: 0; right: auto; max-height: 280px; overflow: auto; min-width: 220px; } +.add-pop .menu-pop .hint { color: var(--subtle); font-size: 11px; padding: 6px 10px 2px; } .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--s2); border: 1px solid var(--border2); border-radius: 8px; padding: 8px 14px; font-family: var(--mono); font-size: 12px; z-index: 60; } .toast.bad { border-color: var(--red); color: var(--red); } .toast[hidden] { display: none; } @@ -139,6 +144,7 @@ main { flex: 1; min-height: 0; display: flex; } Workspace
+
@@ -169,7 +175,10 @@ main { flex: 1; min-height: 0; display: flex; }
- +
+ + +