File: tab_recency.js

package info (click to toggle)
vimium 2.1.2-1.1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,212 kB
  • sloc: javascript: 12,766; makefile: 7
file content (139 lines) | stat: -rw-r--r-- 4,455 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// TabRecency associates an integer with each tab id representing how recently it has been accessed.
// The order of tabs as tracked by TabRecency is used to provide a recency-based ordering in the
// tabs vomnibar.
//
// The values are persisted to chrome.storage.session so that they're not lost when the extension's
// background page is unloaded.
//
// Callers must await TabRecency.init before calling recencyScore or getTabsByRecency.
//
// In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by
// recency, but in practice it does not work across several edge cases. See the comments on #4368.
class TabRecency {
  constructor() {
    this.counter = 1;
    this.tabIdToCounter = {};
    this.loaded = false;
    this.queuedActions = [];
  }

  // Add listeners to chrome.tabs, and load the index from session storage.
  async init() {
    if (this.initPromise) {
      await this.initPromise;
      return;
    }
    let resolveFn;
    this.initPromise = new Promise((resolve, _reject) => {
      resolveFn = resolve;
    });

    chrome.tabs.onActivated.addListener((activeInfo) => {
      this.queueAction("register", activeInfo.tabId);
    });
    chrome.tabs.onRemoved.addListener((tabId) => {
      this.queueAction("deregister", tabId);
    });

    chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
      this.queueAction("deregister", removedTabId);
      this.queueAction("register", addedTabId);
    });

    chrome.windows.onFocusChanged.addListener(async (windowId) => {
      if (windowId == chrome.windows.WINDOW_ID_NONE) return;
      const tabs = await chrome.tabs.query({ windowId, active: true });
      if (tabs[0]) {
        this.queueAction("register", tabs[0].id);
      }
    });

    await this.loadFromStorage();
    while (this.queuedActions.length > 0) {
      const [action, tabId] = this.queuedActions.shift();
      this.handleAction(action, tabId);
    }
    this.loaded = true;
    resolveFn();
  }

  // Loads the index from session storage.
  async loadFromStorage() {
    const tabsPromise = chrome.tabs.query({});
    const storagePromise = chrome.storage.session.get("tabRecency");
    const [tabs, storage] = await Promise.all([tabsPromise, storagePromise]);
    if (storage.tabRecency == null) return;

    let maxCounter = 0;
    for (const counter of Object.values(storage.tabRecency)) {
      if (maxCounter < counter) maxCounter = counter;
    }
    if (this.counter < maxCounter) {
      this.counter = maxCounter;
    }

    this.tabIdToCounter = Object.assign({}, storage.tabRecency);

    // Remove any tab IDs which aren't currently loaded.
    const tabIds = new Set(tabs.map((t) => t.id));
    for (const id in this.tabIdToCounter) {
      if (!tabIds.has(parseInt(id))) {
        delete this.tabIdToCounter[id];
      }
    }
  }

  async saveToStorage() {
    await chrome.storage.session.set({ tabRecency: this.tabIdToCounter });
  }

  // - action: "register" or "unregister".
  queueAction(action, tabId) {
    if (!this.loaded) {
      this.queuedActions.push([action, tabId]);
    } else {
      this.handleAction(action, tabId);
    }
  }

  // - action: "register" or "unregister".
  handleAction(action, tabId) {
    if (action == "register") {
      this.register(tabId);
    } else if (action == "deregister") {
      this.deregister(tabId);
    } else {
      throw new Error(`Unexpected action type: ${action}`);
    }
  }

  register(tabId) {
    this.counter++;
    this.tabIdToCounter[tabId] = this.counter;
    this.saveToStorage();
  }

  deregister(tabId) {
    delete this.tabIdToCounter[tabId];
    this.saveToStorage();
  }

  // Recently-visited tabs get a higher score (except the current tab, which gets a low score).
  recencyScore(tabId) {
    if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
    const tabCounter = this.tabIdToCounter[tabId];
    const isCurrentTab = tabCounter == this.counter;
    if (isCurrentTab) return 0;
    return (tabCounter ?? 1) / this.counter; // tabCounter may be null.
  }

  // Returns a list of tab Ids sorted by recency, most recent tab first.
  getTabsByRecency() {
    if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
    const ids = Object.keys(this.tabIdToCounter);
    ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]);
    return ids.map((id) => parseInt(id));
  }
}

Object.assign(globalThis, { TabRecency });