Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/model/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,52 @@ describe("model", () => {
);
});

it("sorts multi-tab stash insertion order by URL", async () => {
await env.model.options.sync.set({multi_tab_stash_sort: "url"});
await events.next(browser.storage.onChanged);
await events.next(browser.storage.sync.onChanged);
await events.next(env.model.options.sync.onChanged);

const tabs = [
{title: "Gamma", url: "https://example.net/gamma"},
{title: "Alpha", url: "https://example.com/alpha"},
{title: "Beta", url: "https://example.com/beta"},
];

expect(
env.model.sortItemsForMultiTabStashInsertion(tabs).map(t => t.title),
).to.deep.equal(["Alpha", "Beta", "Gamma"]);
expect(tabs.map(t => t.title)).to.deep.equal(["Gamma", "Alpha", "Beta"]);
});

it("sorts multi-tab stash insertion order by date added", async () => {
const tabs = [
{title: "Oldest", url: "https://example.com/oldest"},
{title: "Middle", url: "https://example.com/middle"},
{title: "Newest", url: "https://example.com/newest"},
];

expect(
env.model.sortItemsForMultiTabStashInsertion(tabs).map(t => t.title),
).to.deep.equal(["Oldest", "Middle", "Newest"]);

await env.model.options.sync.set({
multi_tab_stash_sort: "date_added_desc",
});
await events.next(browser.storage.onChanged);
await events.next(browser.storage.sync.onChanged);
await events.next(env.model.options.sync.onChanged);

expect(
env.model.sortItemsForMultiTabStashInsertion(tabs).map(t => t.title),
).to.deep.equal(["Newest", "Middle", "Oldest"]);
expect(tabs.map(t => t.title)).to.deep.equal([
"Oldest",
"Middle",
"Newest",
]);
});

it("allows user selection to override the default choice", async () => {
await browser.tabs.update(env.tabs.real_bob.id, {highlighted: true});
await browser.tabs.update(env.tabs.real_doug.id, {highlighted: true});
Expand Down Expand Up @@ -882,6 +928,35 @@ describe("model", () => {
).to.deep.equal(urls);
});

it("can insert items in a different order than they are processed", async () => {
const items = [
{url: "c", title: "C"},
{url: "a", title: "A"},
{url: "b", title: "B"},
];

const p = env.model.putItemsInFolder({
items,
insertionOrder: [items[1], items[2], items[0]],
toFolder: env.model.bookmarks.folder(env.bookmarks.names.id)!,
toIndex: 2,
});
await events.nextN(browser.bookmarks.onCreated, 3);
await events.nextN(browser.bookmarks.onMoved, 3);
await p;

const folder = env.model.bookmarks.folder(env.bookmarks.names.id)!;
expect(folder.children.map(c => c?.title)).to.deep.equal([
"Doug Duplicate",
"Helen Hidden",
"A",
"B",
"C",
"Patricia Pinned",
"Nate NotOpen",
]);
});

it("moves tabs into the folder", async () => {
const p = env.model.putItemsInFolder({
items: [
Expand Down
73 changes: 72 additions & 1 deletion src/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ export const isNewTab = (item: StashItem): item is NewTab =>
export const isNewFolder = (item: StashItem): item is NewFolder =>
!("id" in item) && "children" in item;

const tabStashSortCollator = new Intl.Collator(undefined, {
usage: "sort",
sensitivity: "base",
numeric: true,
});

export type Source = {
readonly browser_settings: BrowserSettings.Model;
readonly options: Options.Model;
Expand Down Expand Up @@ -356,6 +362,40 @@ export class Model {
return selected.filter(t => !t.pinned);
}

/** Returns a copy of the items in the user's preferred order for insertion
* when stashing multiple tabs. */
sortItemsForMultiTabStashInsertion<T extends StashItem>(items: T[]): T[] {
const sorted = items.map((item, index) => ({item, index}));

const stableSort = (cmp: (a: T, b: T) => number): T[] =>
sorted
.sort((a, b) => cmp(a.item, b.item) || a.index - b.index)
.map(({item}) => item);

switch (this.options.sync.state.multi_tab_stash_sort) {
case "date_added_desc":
return items.slice().reverse();
case "date_added":
return items.slice();
case "title":
return stableSort((a, b) =>
tabStashSortCollator.compare(
("title" in a && a.title) || "",
("title" in b && b.title) || "",
),
);
case "url":
return stableSort((a, b) =>
tabStashSortCollator.compare(
isLeaf(a) ? a.url : "",
isLeaf(b) ? b.url : "",
),
);
default:
return items.slice();
}
}

/** Create a new folder in the stash (creating the stash root itself if it
* does not exist). If the name is not specified, a default name will be
* assigned based on the folder's creation time or the current search term. */
Expand Down Expand Up @@ -433,9 +473,11 @@ export class Model {
) {
const tabs = this.stashableTabsInWindow(window);
if (tabs.length === 0) return;
const items = copyIf(!!options.copy, tabs);

await this.putItemsInFolder({
items: copyIf(!!options.copy, tabs),
items,
insertionOrder: this.sortItemsForMultiTabStashInsertion(items),
toFolder: await this.createStashFolder(undefined, options.parent),
});
}
Expand All @@ -447,6 +489,12 @@ export class Model {
async putSelectedIn(options?: {copy?: boolean; toFolder?: Bookmarks.Folder}) {
const from_items = Array.from(this.selection.selectedItems());
const items = copyIf(options?.copy === true, from_items);
const insertionOrder =
options?.toFolder !== undefined &&
from_items.length > 1 &&
from_items.every(isTab)
? this.sortItemsForMultiTabStashInsertion(items)
: undefined;

let affected_items: StashItem[];
if (options?.toFolder === undefined) {
Expand All @@ -456,6 +504,7 @@ export class Model {
items,
toFolder: options.toFolder,
allowDuplicates: options?.copy === true,
insertionOrder,
});
}
if (!options?.copy) {
Expand Down Expand Up @@ -645,10 +694,12 @@ export class Model {
toFolder: Bookmarks.Folder;
toIndex?: number;
allowDuplicates?: boolean;
insertionOrder?: StashItem[];
task?: TaskMonitor;
}): Promise<Bookmarks.Node[]> {
const to_folder = await this.bookmarks.loaded(options.toFolder);
const items = options.items;
const insertion_start = options.toIndex ?? to_folder.children.length;

// Note: We explicitly DON'T check stashability here because the caller
// has presumably done this for us--and has explicitly chosen what to
Expand Down Expand Up @@ -681,6 +732,7 @@ export class Model {
// the insertion point (i.e. the next inserted item should have index
// `to_index`).
const moved_items: Bookmarks.Node[] = [];
const moved_pairs: {item: StashItem; node: Bookmarks.Node}[] = [];
const close_tabs: Tabs.Tab[] = [];

for (
Expand All @@ -696,6 +748,7 @@ export class Model {
const pos = model_item.position;
await this.bookmarks.move(model_item, to_folder, to_index);
moved_items.push(model_item);
moved_pairs.push({item, node: model_item});
dont_steal_bms.add(model_item.id);

if (pos && pos.parent === to_folder && pos.index < to_index) {
Expand Down Expand Up @@ -776,6 +829,7 @@ export class Model {
node = await createTree(item, to_folder.id, to_index);
}
moved_items.push(node);
moved_pairs.push({item, node});
dont_steal_bms.add(node.id);

// Update the selection state of the chosen bookmark to match the
Expand All @@ -784,6 +838,23 @@ export class Model {
isModelItem(item) && this.selection.info(item).isSelected;
}

if (options.insertionOrder && options.insertionOrder.length > 1) {
const remaining = moved_pairs.slice();
const ordered_nodes = filterMap(options.insertionOrder, item => {
const idx = remaining.findIndex(pair => pair.item === item);
if (idx === -1) return undefined;
return remaining.splice(idx, 1)[0].node;
});

for (let i = 0; i < ordered_nodes.length; ++i) {
await this.bookmarks.move(
ordered_nodes[i],
to_folder,
insertion_start + i,
);
}
}

// Hide/close any tabs which were moved from, since they are now
// (presumably) in the stash.
await this.hideOrCloseStashedTabs(close_tabs);
Expand Down
14 changes: 14 additions & 0 deletions src/model/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@ import {errorLog, UserError} from "../util/oops.js";

export const SHOW_WHAT_OPT = anEnum("sidebar", "tab", "popup", "none");
export const STASH_WHAT_OPT = anEnum("all", "single", "none");
export const MULTI_TAB_STASH_SORT_OPT = anEnum(
"date_added",
"date_added_desc",
"title",
"url",
);
export type ShowWhatOpt = ReturnType<typeof SHOW_WHAT_OPT>;
export type StashWhatOpt = ReturnType<typeof STASH_WHAT_OPT>;
export type MultiTabStashSortOpt = ReturnType<typeof MULTI_TAB_STASH_SORT_OPT>;
export type Capability = "available" | "disabled" | "not-supported";

export type SyncModel = StoredObject<typeof SYNC_DEF>;
Expand Down Expand Up @@ -64,6 +71,13 @@ export const SYNC_DEF = {
is: anEnum("expanded", "collapsed"),
},

// When stashing multiple tabs, what order should the tabs have inside the
// destination group?
multi_tab_stash_sort: {
default: "date_added",
is: MULTI_TAB_STASH_SORT_OPT,
},

// How big should the spacing/fonts be?
ui_metrics: {
default: "normal",
Expand Down
10 changes: 10 additions & 0 deletions src/options/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@
</ul>
</section>

<section class="two-col">
<label for="multi_tab_stash_sort">Sort newly-stashed tabs by:</label>
<select id="multi_tab_stash_sort" v-model="sync.multi_tab_stash_sort">
<option value="date_added">Date Added (Oldest First)</option>
<option value="date_added_desc">Date Added (Newest First)</option>
<option value="title">Title</option>
<option value="url">URL</option>
</select>
</section>

<section class="advanced">
<label>When stashing a single tab:</label>
<ul>
Expand Down
15 changes: 8 additions & 7 deletions src/stash-list/folder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -602,13 +602,14 @@ export default defineComponent({
const win = the.model.tabs.targetWindow.value;
if (!win) return;

the.model.attempt(
async () =>
await the.model.putItemsInFolder({
items: copyIf(ev.altKey, the.model.stashableTabsInWindow(win)),
toFolder: this.folder,
}),
);
the.model.attempt(async () => {
const items = copyIf(ev.altKey, the.model.stashableTabsInWindow(win));
await the.model.putItemsInFolder({
items,
insertionOrder: the.model.sortItemsForMultiTabStashInsertion(items),
toFolder: this.folder,
});
});
},

stashOne(ev: MouseEvent | KeyboardEvent) {
Expand Down
4 changes: 3 additions & 1 deletion src/stash-list/window.vue
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,10 @@ export default defineComponent({
.filter(t => this.isValidChild(t));

if (stashable_children.length === 0) return;
const items = copyIf(ev.altKey, stashable_children);
await the.model.putItemsInFolder({
items: copyIf(ev.altKey, stashable_children),
items,
insertionOrder: the.model.sortItemsForMultiTabStashInsertion(items),
toFolder: await the.model.createStashFolder(),
});
});
Expand Down