Script: Move tabs between windows (JXA)

While DEVONthink has tabs, they don’t work like the standard macOS tabs you see in many other applications. You can drag to rearrange the order of tabs in a window, but that’s it. No drag and drop between windows.

DEVONthink’s tabs are part of the extensive scripting dictionary, however. Some helpful scripts are already available in the Support Assistant: “Attach Tab”, “Detach Tab” and “Combine Document Windows”.

But what if you want to move specific tabs to a specific window?
That’s what this script is for.

  • First you get a list of tabs in the current window. If that’s your only window, selected tabs are moved to a new document window.
  • If you have more than one window, you get another list to select the destination (including options for a new window).

The script handles tabs with webpages as well as database items. But be aware that technically tabs aren’t moved – they’re closed and opened again, so webpages are reloaded.

Below are some notes for other scripters, otherwise you can skip directly to the script.


Notes

AppleScript’s choose from list (and the JXA equivalent chooseFromList) only works with strings and numbers. That’s fine for certain things, but useless if you want to choose between a list of application elements (i.e. object specifiers). For that to work, you need a way to go from object specifier to string representation and back again. I think I came up with a nice JavaScript solution.

The key part (no pun intended) is the tabList and windowList objects. They’re created so that each property key is a string for use in chooseFromList, and the value is an object specifier. That way you can use standard bracket notation to turn chooseFromList results back into object specifiers.

That is, I build the dialog list with Object.keys(listObj) and access object specifiers with listObj[chooseFromListResult]:

// Single list item
const chooseWindow = app.choseFromList(
	Object.keys(windowList),
 {	withTitle: "Select Window",
	multipleSelectionsAllowed: false,
	// ...
	})[0];
const w = windowList[chooseWindow];

// Multiple items
const chooseTabs = app.chooseFromList(
	Object.keys(tabList),
 {	withTitle: "Select Tabs",
	multipleSelectionsAllowed: true,
 	// ...
	});
const tabsToMove = chooseTabs.map( t => tabList[t] );

For windowList I also define two getters, binding functions for opening a new document/main window as object properties.

moveTab(tab, window) function

I started with a bunch of try ... catch blocks, but then realized I could skip that by checking tab instanceof ObjectSpecifier (a URL is just a string). I’m not sure how much of a difference it makes, but it feel cleaner to me.

If the window parameter is missing, the function opens a new document window but returns an object specifier for the tab – meaning you get the window with moveTab(tab).thinkWindow(). I debated returning the thinkWindow directly… But const w = moveTab(tab) seemed harder to understand without looking at the function.


The Script

/*  Move DEVONthink tabs between windows
    Written by troejgaard on Feb 8, 2026  */

(() => {
const app = Application("DEVONthink");
app.includeStandardAdditions = true;

const windows = app.thinkWindows();
if (windows.length === 0) return;
const tabs = windows[0].tabs()
 // Tabs _can_ be empty. Filter those out:
 .filter(t => t.contentRecord() || t.url());
if (tabs.length === 0) return;

const tabList = {};
tabs.forEach((t, i) =>
  tabList[`${i+1}:\t${t.contentRecord()?.name() || t.url()}`] = t
);

const chooseTabs = app.chooseFromList(
	Object.keys(tabList),
 {	withTitle: "Select Tabs",
  	withPrompt: windows.length === 1
		? "Move selected tabs to new document window:"
		: "Move selected tabs to another window:",
	okButtonName: windows.length === 1 ? "Move" : "Continue",
	multipleSelectionsAllowed: true,
	emptySelectionAllowed: false
	});

if (!chooseTabs) return;
const tabsToMove = chooseTabs.map( t => tabList[t] );

if (windows.length === 1) {

	const tab = tabsToMove.shift();
	const w = moveTab(tab).thinkWindow();
	tabsToMove.forEach( t => moveTab(t, w) );
	w.index = 1;
	
} else {
	
	const windowList = {
		...Object.fromEntries(
			windows.map((w, i) => [
				`${i+1}:\t${w.name()}`, w
			])
			.slice(1) // Exclude current window
		),
		get "New Document Window"() {
			return moveTab(tabsToMove.shift()).thinkWindow();
		},
		get "New Main Window"() {
			return app.openWindowFor({
				record: app.currentDatabase.root(),
				enforcement: true
			});
		}
	};
	
	const tabsGrammar = tabsToMove.length > 1 ?
		`${tabsToMove.length} tabs` : '1 tab';
	
	const chooseWindow = app.chooseFromList(
		Object.keys(windowList),
	 {	withTitle: "Select Window",
		withPrompt: `Move ${tabsGrammar} to selected window:`,
		okButtonName: "Move",
		multipleSelectionsAllowed: false,
		emptySelectionAllowed: false
		})[0];
	
	if (!chooseWindow) return;
	
	const w = windowList[chooseWindow];
	tabsToMove.forEach( t => moveTab(t, w) );
	w.index = 1;
	
}

function moveTab(tab, window) {
	if (!tab) throw "'moveTab' called without parameters. " + 
					"'tab' parameter is required.";
	const content = tab.contentRecord() || tab.url();
	tab.close();
	const params = {};
	params[content instanceof ObjectSpecifier ? 'record' : 'url'] = content;
	if (window) params['in'] = window;
	return app.openTabFor(params);
}

})()
3 Likes