Add tab group commands

Functionality implemented:
- :tgroupcreate to create a tab group
- :tgroupswitch to switch to or create a tab group
- :tgrouplast to switch to the previously active tab group
- :tgrouprename to rename the current tab group
- :tgroupclose to close all tabs in the current tab group
- :tgroupmove to move the current tab to another tab group
- :tgroupabort to destroy all tab group information and show all tabs
- :tabgroup... aliases for the above commands
- Completion for :tgroupswitch and :tgroupmove
- Restoring tab group setup on firefox start (seems to just work without any extra code)
- Basic mode indicator
- Setting for initial urls for to use newly created tab groups (after first)
- Setting for whether :tab shows hidden tabs.

Edge cases handled
- Opening tabs outside of tridactyl mechanisms (e.g. firefox gui, C-t, etc.)
- Switching to a hidden tab
- Detaching and attaching tabs
- Pinned tabs (they can't be hidden; the commands ignore them)
- Various other edge cases

Fixes #811.
This commit is contained in:
Fox Kiester 2020-05-01 17:42:48 -04:00
parent d1eec89c7a
commit 988ec5b770
No known key found for this signature in database
GPG key ID: 316E205D6017DBFF
11 changed files with 752 additions and 7 deletions

View file

@ -59,5 +59,7 @@ Since Tridactyl aims to provide all the features Vimperator and Pentadactyl had,
- This allows us to use Firefox's built-in find-in-page API, for, for example, allowing you to bind find-next and find-previous to `n` and `N`.
- Monitor extension usage and manage themes:
- Tridactyl needs this to integrate with and avoid conflicts with other extensions. For example, Tridactyl's contextual identity features use this to cooperate with the Multi-Account Containers extension.
- Hide tabs:
- Tridactyl needs this for tab group commands, which allow associating names with different groups of tabs and showing the tabs from only of those groups at a time.
[betas]: https://tridactyl.cmcaine.co.uk/betas/?sort=time&order=desc

View file

@ -122,6 +122,7 @@ export function enableCompletions() {
RssCompletionSource,
SessionsCompletionSource,
SettingsCompletionSource,
TabGroupCompletionSource,
WindowCompletionSource,
ExtensionsCompletionSource,
]
@ -392,6 +393,7 @@ Messaging.addListener("commandline_frame", Messaging.attributeCaller(SELF))
import { getCommandlineFns } from "@src/lib/commandline_cmds"
import { KeyEventLike } from "./lib/keyseq"
import { TabGroupCompletionSource } from "./completions/TabGroup"
commandline_state.fns = getCommandlineFns(commandline_state)
Messaging.addListener(
"commandline_cmd",

View file

@ -168,9 +168,19 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
}
private async fillOptions() {
const tabs: browser.tabs.Tab[] = await browserBg.tabs.query({
currentWindow: true,
})
let tabs: browser.tabs.Tab[]
if (config.get("tabshowhidden") === "true") {
tabs = await browserBg.tabs.query({
currentWindow: true
})
} else {
tabs = await browserBg.tabs.query({
currentWindow: true,
hidden: false
})
}
const options = []
// Get alternative tab, defined as last accessed tab.
tabs.sort((a, b) => b.lastAccessed - a.lastAccessed)

View file

@ -0,0 +1,70 @@
import * as Completions from "@src/completions"
import { tgroups, windowTgroup, tgroupTabs } from "@src/lib/tab_groups"
class TabGroupCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(group: string, tabCount: number) {
super()
this.value = group
this.fuseKeys.push(group)
this.html = html`<tr class="TabGroupCompletionOption option">
<td class="title">${group}</td>
<td class="tabcount">${tabCount} tab${
tabCount !== 1 ? "s" : ""
}</td>
</tr>`
}
}
export class TabGroupCompletionSource extends Completions.CompletionSourceFuse {
public options: TabGroupCompletionOption[]
constructor(private _parent: any) {
super(
["tgroupswitch", "tgroupmove"],
"TabGroupCompletionSource",
"Tab Groups",
)
this.updateOptions()
this._parent.appendChild(this.node)
}
async onInput(_: string) {}
async filter(exstr: string) {
this.lastExstr = exstr
const [prefix] = this.splitOnPrefix(exstr)
// Hide self and stop if prefixes don't match
if (prefix) {
// Show self if prefix and currently hidden
if (this.state === "hidden") {
this.state = "normal"
}
} else {
this.state = "hidden"
return
}
return this.updateDisplay()
}
private async updateOptions() {
const currentGroup = await windowTgroup()
const otherGroups = [...(await tgroups())].filter(
group => group !== currentGroup,
)
this.options = await Promise.all(
otherGroups.map(async group => {
const tabCount = (await tgroupTabs(group)).length
const o = new TabGroupCompletionOption(group, tabCount)
o.state = "normal"
return o
}),
)
return this.updateDisplay()
}
}

View file

@ -155,6 +155,7 @@ import * as urlutils from "@src/lib/url_util"
import * as scrolling from "@src/content/scrolling"
import * as R from "ramda"
import * as visual from "@src/lib/visual"
import { tabTgroup } from "./lib/tab_groups"
/* tslint:disable:import-spacing */
;(window as any).tri = Object.assign(Object.create(null), {
browserBg: webext.browserBg,
@ -295,16 +296,18 @@ config.getAsync("modeindicator").then(mode => {
})
}
addContentStateChangedListener((property, oldMode, oldValue, newValue) => {
addContentStateChangedListener(async (property, oldMode, oldValue, newValue) => {
let mode = newValue
let group = ""
let suffix = ""
let result = ""
if (property !== "mode") {
if (property === "suffix") {
mode = oldMode
suffix = newValue
} else {
return
} else if (property === "group") {
mode = oldMode
group = newValue
}
}
@ -330,10 +333,17 @@ config.getAsync("modeindicator").then(mode => {
} else {
result = mode
}
const modeindicatorshowkeys = Config.get("modeindicatorshowkeys")
if (modeindicatorshowkeys === "true" && suffix !== "") {
result = mode + " " + suffix
}
const tabGroup = await tabTgroup()
if (tabGroup) {
result = result + " | " + tabGroup
}
logger.debug(
"statusindicator: ",
result,

View file

@ -20,6 +20,8 @@ export class PrevInput {
class ContentState {
mode: ModeName = "normal"
suffix = ""
// to trigger status indicator updates
group: string = ""
}
export type ContentStateProperty =
@ -27,6 +29,7 @@ export type ContentStateProperty =
| "cmdHistory"
| "prevInputs"
| "suffix"
| "group"
export type ContentStateChangedCallback = (
property: ContentStateProperty,

View file

@ -164,6 +164,7 @@ import * as Updates from "@src/lib/updates"
import * as Extensions from "@src/lib/extension_info"
import * as webrequests from "@src/background/webrequests"
import * as commandsHelper from "@src/background/commands"
import { tgroups, tgroupActivate, setTabTgroup, setWindowTgroup, setTgroups, windowTgroup, tgroupClearOldInfo, tgroupLastTabId, tgroupTabs, clearAllTgroupInfo, tgroupActivateLast, tgroupHandleTabActivated, tgroupHandleTabCreated, tgroupHandleTabAttached, tgroupHandleTabUpdated, tgroupHandleTabRemoved, tgroupHandleTabDetached } from "./lib/tab_groups"
ALL_EXCMDS = {
"": BGSELF,
@ -2803,7 +2804,208 @@ export async function recontain(containerName: string) {
}
// }}}
//
// {{{ TAB GROUPS
/** @hidden */
//#background_helper
// {
browser.tabs.onCreated.addListener(tgroupHandleTabCreated)
browser.tabs.onRemoved.addListener(tgroupHandleTabRemoved)
browser.tabs.onDetached.addListener(tgroupHandleTabDetached)
browser.tabs.onAttached.addListener(tgroupHandleTabAttached)
browser.tabs.onActivated.addListener(tgroupHandleTabActivated)
browser.tabs.onUpdated.addListener(tgroupHandleTabUpdated)
// }
/** @hidden */
//#content
export function setContentStateGroup(name: string) {
contentState.group = name
}
/**
* Create a new tab group in the current window.
*
* Tab groups are a way of organizing different groups of related tabs within a
* single window. Groups allow you to have different named contexts and show
* only the tabs for a single group at a time.
*
* @param name The name of the tab group to create.
*
* If no tab groups exist, set the tab group name for all existing tabs in the
* window. Otherwise open a new tab and hide all tabs in the old tab group.
*
* Tab groups exist only for a single window.
*
*/
//#background
export async function tgroupcreate(name: string) {
const promises = []
const groups = await tgroups()
if (groups.has(name)) {
throw new Error(`Tab group "${name}" already exists`)
}
if (groups.size > 0) {
await setWindowTgroup(name)
const initialUrl = await config.get("tabgroupnewtaburls")[name]
await tabopen(initialUrl)
promises.push(tgroupTabs(name, true).then(tabs => browserBg.tabs.hide(tabs.map(tab => tab.id))))
} else {
promises.push(browser.tabs.query({currentWindow: true}).then((tabs) => {
setTabTgroup(name, tabs.map(({ id }) => id))
// trigger status line update
setContentStateGroup(name)
}))
promises.push(setWindowTgroup(name))
}
groups.add(name)
promises.push(setTgroups(groups))
return Promise.all(promises).then(() => name)
}
/**
* Switch to a different tab group, hiding all other tabs.
*
* @param name The name of the tab group to switch to.
*
* If the tab group does not exist, act like tgroupcreate.
*
*/
//#background
export async function tgroupswitch(name: string) {
if (name == await windowTgroup()) {
throw new Error(`Already on tab group "${name}"`)
}
const groups = await tgroups()
if (groups.size > 0) {
if (groups.has(name)) {
return tgroupActivate(name).then(() => name)
} else {
return tgroupcreate(name).then(() => name)
}
} else {
return tgroupcreate(name).then(() => name)
}
}
/**
* Switch to the previously active tab group.
*/
//#background
export async function tgrouplast() {
if ((await tgroups()).size < 2) {
throw new Error("No last tab group")
}
return tgroupActivateLast()
}
/**
* Rename the current tab group.
*
* @param name The new name of the tab group.
*
*/
//#background
export async function tgrouprename(name: string) {
if ((await tgroups()).size == 0) {
throw new Error("No tab groups exist")
}
return tgroupClearOldInfo(await windowTgroup(), name).then(() => {
// trigger status line update
setContentStateGroup(name)
return name
})
}
/**
* Close the current tab group.
*
* First switch to the previously active tab group. Do nothing if there is only
* one tab group.
*
*/
//#background
export async function tgroupclose() {
const groups = await tgroups()
if (groups.size == 0) {
throw new Error("No tab groups exist")
} else if (groups.size == 1) {
throw new Error("This is the only tab group")
} else if (groups.size > 1) {
const closeGroup = await windowTgroup()
const newTabGroup = await tgroupActivateLast()
await tgroupTabs(closeGroup).then(tabs => {
browser.tabs.remove(tabs.map(tab => tab.id))
})
return tgroupClearOldInfo(closeGroup).then(() => newTabGroup)
}
}
/**
* Move the current tab to another tab group.
*
* @param name The name of the tab group to move the tab to.
*
* If this is the last tab in the tab group, also switch to tab group, keeping
* the current tab active.
*
*/
//#background
export async function tgroupmove(name: string) {
const groups = await tgroups()
const currentGroup = await windowTgroup()
if (groups.size == 0) {
throw new Error("No tab groups exist")
}
if (!groups.has(name)) {
throw new Error(`Tab group "${name}" does not exist`)
}
if (name == currentGroup) {
throw new Error(`Tab is already on group "${name}"`)
}
const tabCount = await tgroupTabs(currentGroup).then(tabs => tabs.length)
await setTabTgroup(name)
const currentTabId = await activeTabId()
// switch to other group if this is the last tab in the current group
if (tabCount == 1) {
return Promise.all([
tgroupClearOldInfo(currentGroup, name),
tgroupTabs(name).then(tabs => {
browserBg.tabs.show(tabs.map(tab => tab.id))
}),
]).then(() => name)
} else {
const lastTabId = await tgroupLastTabId(currentGroup)
await tabSetActive(lastTabId)
return browser.tabs.hide(currentTabId).then(() => currentGroup)
}
}
/**
* Delete all tab group information for the current window and show all tabs.
*
*/
//#background
export async function tgroupabort() {
if ((await tgroups()).size == 0) {
throw new Error("No tab groups exist")
}
return clearAllTgroupInfo().then(() => undefined)
}
// }}}
// {{{ MISC
//#background

View file

@ -543,6 +543,13 @@ export class default_config {
o: "open",
w: "winopen",
t: "tabopen",
tabgroupabort: "tgroupabort",
tabgroupclose: "tgroupclose",
tabgroupcreate: "tgroupcreate",
tabgrouplast: "tgrouplast",
tabgroupmove: "tgroupmove",
tabgrouprename: "tgrouprename",
tabgroupswitch: "tgroupswitch",
tabnew: "tabopen",
tabm: "tabmove",
tabo: "tabonly",
@ -971,6 +978,16 @@ export class default_config {
*/
auconcreatecontainer: "true" | "false" = "true"
/**
* Initial urls to navigate to when creating a new tab for a new tab group.
*/
tabgroupnewtaburls = {}
/**
* Whether :tab shows completions for hidden tabs (e.g. tabs in other tab groups).
*/
tabshowhidden: "true" | "false" = "false"
/**
* Number of most recent results to ask Firefox for. We display the top 20 or so most frequently visited ones.
*/

413
src/lib/tab_groups.ts Normal file
View file

@ -0,0 +1,413 @@
import {
activeTabId,
activeWindowId,
browserBg,
removeActiveWindowValue,
} from "./webext"
/**
* Return a set of the current window's tab groups (empty if there are none).
*
*/
export async function tgroups() {
const groups = await browserBg.sessions.getWindowValue(
await activeWindowId(),
"tridactyl-tgroups",
)
return new Set<string>(groups as string[])
}
/**
* Set the current window's tab groups.
*
* @param groups The set of groups.
*
*/
export async function setTgroups(groups: Set<string>) {
return browserBg.sessions.setWindowValue(
await activeWindowId(),
"tridactyl-tgroups",
[...groups],
)
}
/**
* Clear the current window's tab groups.
*
*/
export function clearTgroups() {
removeActiveWindowValue("tridactyl-tgroups")
}
/**
* Return the active tab group for the a window or undefined.
*
* @param id The id of the window. Use the current window if not specified.
*
*/
export async function windowTgroup(id?: number) {
if (id === undefined) {
id = await activeWindowId()
}
return (browserBg.sessions.getWindowValue(
id,
"tridactyl-active-tgroup",
) as unknown) as string
}
/**
* Set the active tab group for a window.
*
* @param name The name of the new active tab group.
* @param id The id of the window. Use the current window if not specified.
*
*/
export async function setWindowTgroup(name: string, id?: number) {
if (id === undefined) {
id = await activeWindowId()
}
return browserBg.sessions.setWindowValue(
id,
"tridactyl-active-tgroup",
name,
)
}
/**
* Clear the active tab group for the current window.
*
*/
export function clearWindowTgroup() {
return removeActiveWindowValue("tridactyl-active-tgroup")
}
/**
* Return a tab's tab group.
*
* @param id The id of the tab. Use the current tab if not specified.
*
*/
export async function tabTgroup(id?: number) {
if (id === undefined) {
id = await activeTabId()
}
return (browserBg.sessions.getTabValue(
id,
"tridactyl-tgroup",
) as unknown) as string
}
/**
* Return a list of tab ids.
*
* @param id An id, a list of ids, or undefined.
*
* If id is undefined, return a list of the current tab id.
*
*/
async function tabIdsOrCurrent(ids?: number | number[]): Promise<number[]> {
if (!ids) {
ids = [await activeTabId()]
} else if (!Array.isArray(ids)) {
ids = [ids]
}
return ids
}
/**
* Set the a tab's tab group.
*
* @param name The name of the tab group.
* @param id The id(s) of the tab(s). Use the current tab if not specified.
*
*/
export async function setTabTgroup(name: string, id?: number | number[]) {
const ids = await tabIdsOrCurrent(id)
return Promise.all(
ids.map(id => {
browserBg.sessions.setTabValue(id, "tridactyl-tgroup", name)
}),
)
}
/**
* Clear all the tab groups.
*
* @param id The id(s) of the tab(s). Use the current tab if not specified.
*
*/
export async function clearTabTgroup(id?: number | number[]) {
const ids = await tabIdsOrCurrent(id)
return Promise.all(
ids.map(id => {
browserBg.sessions.removeTabValue(id, "tridactyl-tgroup")
}),
)
}
/**
* Return a list of all tabs in a tab group.
*
* @param name The name of the tab group.
* @param other Whether to return the tabs not in the tab group instead.
* @param id The id of the window. Use the current window if not specified.
*
*/
export async function tgroupTabs(
name: string,
other: boolean = false,
id?: number,
): Promise<browser.tabs.Tab[]> {
if (id === undefined) {
id = await activeWindowId()
}
return browserBg.tabs.query({ windowId: id }).then(async tabs => {
const sameGroupIndices = await Promise.all(
tabs.map(async ({ id }) => {
const groupMatched = (await tabTgroup(id)) == name
return other ? !groupMatched : groupMatched
}),
)
tabs = tabs.filter((_, index) => sameGroupIndices[index])
return tabs
})
}
/**
* Return the id of the last selected tab in a tab group.
*
* @param name The name of the tab group.
* @param previous Whether to return the tab selected before the last tab.
*
*/
export async function tgroupLastTabId(name: string, previous: boolean = false) {
const tabs = await tgroupTabs(name)
tabs.sort((a, b) => b.lastAccessed - a.lastAccessed)
if (previous) {
return tabs[1].id
} else {
return tabs[0].id
}
}
/**
* Clear stored information for a tab group.
*
* @param name The name of the tab group.
* @param newName A name to rename the group to.
* @param id The id of the window. Use the current window if not specified.
*
* If newName is specified, add it as a stored group (if it doesn't already
* exist), set the current window group to it, and set the group for all tabs in
* the old group to it.
*
*/
export async function tgroupClearOldInfo(
oldName: string,
newName?: string,
id?: number,
) {
const promises = []
const groups = await tgroups()
groups.delete(oldName)
if (newName) {
groups.add(newName)
}
promises.push(setTgroups(groups))
if (id === undefined) {
id = await activeWindowId()
}
if (newName) {
promises.push(setWindowTgroup(newName, id))
promises.push(
tgroupTabs(oldName, false, id).then(tabs => {
setTabTgroup(
newName,
tabs.map(tab => tab.id),
)
}),
)
}
return Promise.all(promises)
}
/**
* Activate the previously active tab in a tab group.
*
* @param name The name of the tab group to switch to.
*
*/
export async function tgroupActivate(name: string) {
const lastActiveTabId = await tgroupLastTabId(name)
// this will trigger tgroupHandleTabActivated
return browserBg.tabs.update(lastActiveTabId, { active: true })
}
/**
* Activate the last active tab in the last active tab group.
*
* Return the name of activated tab group.
*
*/
export async function tgroupActivateLast() {
const otherGroupsTabs = await tgroupTabs(await windowTgroup(), true)
otherGroupsTabs.sort((a, b) => b.lastAccessed - a.lastAccessed)
const lastTabId = otherGroupsTabs[0].id
return browserBg.tabs
.update(lastTabId, { active: true })
.then(() => tabTgroup(lastTabId))
}
/**
* Clear all stored tab group information.
*
*/
export async function clearAllTgroupInfo() {
return Promise.all([
clearTgroups(),
clearWindowTgroup(),
browser.tabs.query({ currentWindow: true }).then(async tabs => {
const ids = tabs.map(tab => tab.id)
await browser.tabs.show(ids)
return clearTabTgroup(ids)
}),
])
}
/**
* Set the tab's tab group to its window's active tab group if there is one.
*
* Do nothing if the tab is already associated with a tab group.
*
* @param tab The tab that was just created.
*
*/
export async function tgroupHandleTabCreated(tab: browser.tabs.Tab) {
const windowGroup = await windowTgroup(tab.windowId)
if (windowGroup) {
const tabGroup = await tabTgroup(tab.id)
if (!tabGroup) {
return setTabTgroup(windowGroup, tab.id)
}
}
}
/**
* Set the tab's tab group to its window's active tab group if there is one.
*
* @param tabId The id of the tab that was just attached to this window.
*
*/
export async function tgroupHandleTabAttached(tabId: number, attachInfo) {
// NOTE this doesn't need to worry about a tab on another window previously
// being pinned because tabs become unpinned when you move them between
// windows
const windowGroup = await windowTgroup(attachInfo.newWindowId)
if (windowGroup) {
return setTabTgroup(windowGroup, tabId)
}
}
/**
* Handle tab activation, possibly switching tab groups.
*
* If the new tab is from a different tab group, set it as the new tab group,
* show all its tabs, and hide all tabs from the old tab group.
*/
export async function tgroupHandleTabActivated(activeInfo) {
const windowGroup = await windowTgroup(activeInfo.windowId)
const tabGroup = await tabTgroup(activeInfo.tab)
const promises = []
if (windowGroup && tabGroup && windowGroup != tabGroup) {
await setWindowTgroup(tabGroup, activeInfo.windowId)
promises.push(
tgroupTabs(tabGroup, false, activeInfo.windowId).then(tabs => {
return browserBg.tabs.show(tabs.map(tab => tab.id))
}),
)
promises.push(
tgroupTabs(tabGroup, true, activeInfo.windowId).then(tabs => {
return browserBg.tabs.hide(tabs.map(tab => tab.id))
}),
)
}
return Promise.all(promises)
}
/**
* Set or clear a tab's group if it was pinned or unpinned respectively.
*
* @param tabId The id of the tab that was just updated.
*
*/
export async function tgroupHandleTabUpdated(
tabId: number,
changeInfo,
tab: browser.tabs.Tab,
) {
if (changeInfo.pinned !== undefined) {
const windowGroup = await windowTgroup(tab.windowId)
if (windowGroup) {
if (changeInfo.pinned) {
return clearTabTgroup(tabId)
} else {
return setTabTgroup(windowGroup, tabId)
}
}
}
}
/**
* Handle the last tab in a tab group being closed.
*
* Clear its information.
*
*/
export async function tgroupHandleTabRemoved(_tabId: number, removeInfo) {
if (!removeInfo.isWindowClosing) {
const windowGroup = await windowTgroup(removeInfo.windowId)
const tabCount = await tgroupTabs(
windowGroup,
false,
removeInfo.windowId,
).then(tabs => tabs.length)
if (tabCount == 0) {
return tgroupClearOldInfo(
windowGroup,
undefined,
removeInfo.windowId,
)
}
}
}
/**
* Handle the last tab in a tab group being moved to another window.
*
* Clear its information.
*
*/
export async function tgroupHandleTabDetached(tabId: number, detachInfo) {
// clear tab's stored group; will automatically be changed if there are
// groups on the other window but otherwise it will still show up in the
// mode indicator
clearTabTgroup(tabId)
const windowGroup = await windowTgroup(detachInfo.oldWindowId)
const tabCount = await tgroupTabs(
windowGroup,
false,
detachInfo.oldWindowId,
).then(tabs => tabs.length)
if (tabCount == 0) {
return tgroupClearOldInfo(
windowGroup,
undefined,
detachInfo.oldWindowId,
)
}
}

View file

@ -61,6 +61,21 @@ export async function activeTabId() {
return (await activeTab()).id
}
/**
* Return the active window's id.
*
*/
export async function activeWindowId() {
return (await browserBg.windows.getCurrent()).id
}
export async function removeActiveWindowValue(value) {
browserBg.sessions.removeWindowValue(
await activeWindowId(),
value,
)
}
export async function activeTabContainerId() {
return (await activeTab()).cookieStoreId
}

View file

@ -90,6 +90,7 @@
"search",
"sessions",
"storage",
"tabHide",
"tabs",
"topSites",
"management",