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..cb2e103 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
@@ -62,6 +63,7 @@ main { flex: 1; min-height: 0; display: flex; }
.pane-head .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; border-color: transparent; font-weight: 600; }
.pane-head .name:hover { border-color: var(--border2); }
.pane-head input.page { width: 42px; text-align: right; padding: 2px 5px; }
+.pane-head input.find { width: 130px; min-width: 70px; padding: 2px 6px; }
.pane-head .pages { color: var(--subtle); }
.pane-body { flex: 1; min-height: 0; position: relative; background: #2a2a28; }
.scroller { position: absolute; inset: 0; overflow: auto; }
@@ -127,7 +129,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 +145,7 @@ main { flex: 1; min-height: 0; display: flex; }
Workspace
+
@@ -169,7 +176,10 @@ main { flex: 1; min-height: 0; display: flex; }
-
+
+
+
+