Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8a1a72e
feat(import): bookmarks from a file (IM-13, IM-05) through the import…
siyabendoezdemir Oct 10, 2026
8993d9f
feat(import): a pasted list of links becomes one pinned folder (IM-12)
siyabendoezdemir Oct 10, 2026
1488554
feat(import): import from the browsers on this computer (IM-01 to IM-…
siyabendoezdemir Oct 10, 2026
3e3d6c8
feat(import): history from other browsers into the profile's History …
siyabendoezdemir Oct 10, 2026
f2297ec
feat(import): starter suggestions from imported history in onboarding…
siyabendoezdemir Oct 10, 2026
d7893c9
feat(import): export pinned items as a bookmarks file (IM-17)
siyabendoezdemir Oct 10, 2026
85fae5b
feat(import): list a Chromium profile's extensions, not importable ye…
siyabendoezdemir Oct 10, 2026
30de427
Merge origin/main (PR #21, D52 scope) into lane/import
siyabendoezdemir Oct 10, 2026
27f5dc6
fix(import): Orbit's words name no other browser's product; a unit te…
siyabendoezdemir Oct 10, 2026
2062278
Merge origin/main (PR #18, chrome) into lane/import
siyabendoezdemir Oct 10, 2026
c7ecf4c
fix(import): a file dropped on the sidebar never opens a local addres…
siyabendoezdemir Oct 10, 2026
7b03a2b
fix(import): every scan of a file or paste is linear, and files are r…
siyabendoezdemir Oct 10, 2026
0a429ee
fix(import): copies of another browser's files live under user data, …
siyabendoezdemir Oct 10, 2026
2ba683f
fix(import): bookmarks are all or nothing, and an ended import keeps …
siyabendoezdemir Oct 10, 2026
0b328e3
fix(import): the summary names every item left out: skipped-only fold…
siyabendoezdemir Oct 10, 2026
c8df9ca
fix(import): a hostile Preferences file can't stall main: JSON over 1…
siyabendoezdemir Oct 10, 2026
b74c953
test(import): deep and oversized files end-to-end (F1, F2); the test …
siyabendoezdemir Oct 10, 2026
9d8fafa
test(clean-room): read tests, fixtures and scripts too, in any case a…
siyabendoezdemir Oct 10, 2026
499654e
docs: import checklist notes after the review (F5, F7, F9)
siyabendoezdemir Oct 10, 2026
9d06199
docs: IM-01 says Cancel is offered only while the source is read (F9)
siyabendoezdemir Oct 10, 2026
8a285c0
Merge origin/main (PR #20, downloads) into lane/import
siyabendoezdemir Oct 10, 2026
86c5c40
fix(import): bookmarks are made out of sight and shown whole; a quit …
siyabendoezdemir Oct 10, 2026
bc6723d
fix(import): a long list of links stays out of the import window; pas…
siyabendoezdemir Oct 10, 2026
ce46735
fix(import): an import's unfinished tabs are listed nowhere, a placed…
siyabendoezdemir Oct 10, 2026
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
44 changes: 22 additions & 22 deletions FEATURES.md

Large diffs are not rendered by default.

24 changes: 19 additions & 5 deletions docs/feature-status.json

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions scripts/features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ const ignore = (f) => f.startsWith('SOURCES') || f === 'FEATURES.md'
const found = readdirSync(specDir).filter((f) => f.endsWith('.md') && !ignore(f))
const files = [...ORDER.filter((f) => found.includes(f)), ...found.filter((f) => !ORDER.includes(f)).sort()]

/** Arc-specific names stay in the spec; Orbit's checklist uses neutral names only. */
/**
* The spec's own product names stay in the spec; Orbit's checklist uses neutral names only (D57).
* The name is spelled out, as in the clean-room test, so this file doesn't hold it.
*/
const NAME = ['A', 'r', 'c'].join('')
const neutral = (title) =>
title
.replace(/\s*\((?:Arc[^)]*|[^)]*\bArc\b[^)]*)\)/g, '')
.replace(/\s*\[[^\]]*\bArc\b[^\]]*\]/g, '')
.replace(new RegExp(`\\s*\\((?:${NAME}[^)]*|[^)]*\\b${NAME}\\b[^)]*)\\)`, 'g'), '')
.replace(new RegExp(`\\s*\\[[^\\]]*\\b${NAME}\\b[^\\]]*\\]`, 'g'), '')
.replace(new RegExp(`\\bfrom ${NAME}\\b`, 'g'), 'from a browser Orbit can’t read')
.trim()

const sections = []
Expand Down
620 changes: 620 additions & 0 deletions scripts/import-fixtures.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/main/archive/restore-data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BackupRestoreResult, BackupUndoResult } from '../../shared/commands/archive'
import type { MainContext } from '../context'
import { stopImportsForRestore } from '../import/unplaced'
import type { BackupStore } from './backups'
import { sidebarUndo } from './inbox'
import { buildSnapshot, restoreSidebar, type SidebarSnapshot } from './snapshot'
Expand Down Expand Up @@ -51,6 +52,8 @@ export class RestoreData {
console.error('[backups] could not back up the sidebar before a restore; nothing was changed', err)
return { restored: false, reason: 'backup-failed' }
}
// A running import stops first (D65 (3)), so what this restore replaces holds nothing of it.
stopImportsForRestore()
const at = this.now()
const state = this.ctx.store.state
const replaced = buildSnapshot(state, at)
Expand Down
5 changes: 4 additions & 1 deletion src/main/archive/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import { tabTitle } from '../../shared/tabs'
import { sectionOf, sidebarTabOrder, splitLeaves } from '../../shared/tree'
import type { MainContext } from '../context'
import { stopImportsForRestore } from '../import/unplaced'
import { normalizeState } from '../persist'
import { dedupeDisplayed, mostRecentTab, shownElsewhere } from '../tabs/model'
import type { SavedHistory } from '../tabs/runtime'
Expand Down Expand Up @@ -614,14 +615,16 @@ function historyOf(ctx: Pick<MainContext, 'tabs'>, tabId: TabId): SavedHistory |
* Puts the sidebar back as a backup has it (AR-11): one state update (`planRestore`,
* `applyRestore`), then what follows outside the state. Returning tabs get their history before
* they can wake; tabs that left hand theirs to the Archive and close their page; tabs now in
* another profile start again in its session.
* another profile start again in its session. A running import stops first: the tabs it made out
* of sight go, and never reach the Archive (D65 (3)).
*/
export function restoreSidebar(
ctx: Pick<MainContext, 'store' | 'tabs'>,
archive: Pick<ArchiveStore, 'get' | 'idsOfTab' | 'delete'>,
snap: SidebarSnapshot,
now = Date.now(),
): RestorePlan {
stopImportsForRestore()
const state = ctx.store.state
const hot = new Map<TabId, ArchivedTab[]>()
for (const a of state.archive) if (a.tabId) hot.set(a.tabId, [...(hot.get(a.tabId) ?? []), a])
Expand Down
9 changes: 7 additions & 2 deletions src/main/commandbar/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ArchivedTab, FolderId, ItemId, OrbitState, ProfileId, SpaceId, Tab
import { splitLeaves } from '../../shared/tree'
import { type ArchiveSource, archiveSource } from '../archive/inbox'
import { hostOf, urlKeyOf } from '../history/store'
import { isUnplacedImportTab, unplacedImportVersion } from '../import/unplaced'
import { initials } from './match'
import type { ActionDoc, ArchiveDoc, FolderDoc, SpaceDoc, TabDoc } from './rank'

Expand Down Expand Up @@ -60,14 +61,18 @@ export class SourceCache {
private names: Record<SpaceId, string> = {}
private actionDocs: ActionDoc[] | undefined

/** Every tab in every Space (pinned, Favorites, Today, folders, splits). */
/**
* Every tab in every Space (pinned, Favorites, Today, folders, splits), and a Peek's or a mini window's. Not an import's
* tabs that no section shows yet: nothing may reach them until their folder is shown (D65 (1)).
*/
tabs(state: OrbitState): TabDoc[] {
const layout = this.layoutOf(state)
const refs = [state.tabs, layout]
const refs = [state.tabs, layout, unplacedImportVersion()]
if (sameRefs(refs, this.tabsRef)) return this.tabDocs
this.tabsRef = refs
const docs: TabDoc[] = []
for (const tab of Object.values(state.tabs)) {
if (isUnplacedImportTab(tab.id) && !layout.section.has(tab.id)) continue
let doc = this.tabDocCache.get(tab)
if (!doc) {
doc = makeTabDoc(tab, layout.section.get(tab.id))
Expand Down
2 changes: 1 addition & 1 deletion src/main/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function registerFeatures(ctx: MainContext): void {
registerExtensions(ctx)
registerDownloads(ctx)
registerSettings(ctx)
registerImport(ctx)
registerImport(ctx, history)
registerUpdates(ctx)
// Last, so its guard on new tabs and windows goes before every other (Peek's, Mini Window's).
registerInternalPages(ctx)
Expand Down
68 changes: 68 additions & 0 deletions src/main/history/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export interface HostInfo {
lastVisit: number
}

/** A page from another browser's history (IM-07, added by the import lane). */
export interface ImportedPage {
url: string
title: string
visitCount: number
typedCount: number
/** Its latest visit times, in ms (the newest MAX_VISITS_PER_ENTRY are kept). */
visits: number[]
}

export interface HistoryStoreOptions {
now?: () => number
/** Background compaction once appended bytes pass max(this, last snapshot size). Default 1 MiB. */
Expand Down Expand Up @@ -249,6 +259,64 @@ export class HistoryStore {
this.write(rec)
}

/**
* Adds pages imported from another browser (IM-07, import lane), after `load()`: one pass, no log
* record per visit. A visit already here isn't added again, so importing the same history twice
* changes nothing (IM-15). Call it a chunk at a time, then `finishImport()`. Returns the pages
* that were new or gained visits.
*/
importPages(pages: readonly ImportedPage[]): number {
if (!this.isLoaded) throw new Error('History isn’t loaded yet')
if (this.dropped) return 0
let changed = 0
for (const p of pages) {
if (!isRecordable(p.url)) continue
const times = [...new Set(p.visits.filter(Number.isFinite))].sort((a, b) => a - b)
const recent = times.slice(-MAX_VISITS_PER_ENTRY)
const known = this.map.get(p.url)
// Visits this page already has mean it was imported before: only its newer visits are added
// (an entry keeps its newest visits only, so older ones may be counted already).
const before = known && recent.some((at) => known.visits.includes(at)) ? known : undefined
const fresh = before
? recent.filter(
(at) =>
!before.visits.includes(at) && (before.visits.length < MAX_VISITS_PER_ENTRY || at > before.visits[0]!),
)
: recent
const first = fresh[0]
const last = fresh[fresh.length - 1]
if (first === undefined || last === undefined) continue
const visits = before ? fresh.length : Math.max(Number(p.visitCount) || 0, times.length)
const typed = before ? 0 : Math.min(Math.max(0, Number(p.typedCount) || 0), visits)
const e = this.entry(this.map, p.url, first)
const title = p.title.trim().slice(0, MAX_TITLE)
if (!e.title && title) {
e.title = title
e.titleLower = title.toLowerCase()
}
e.visitCount += visits
e.typedCount += typed
e.firstVisit = Math.min(e.firstVisit, first)
e.lastVisit = Math.max(e.lastVisit, last)
for (const at of fresh) pushVisit(e.visits, at)
this.countHost(e, visits, typed, e.lastVisit)
// A log that can't be read gets each addition in its side file; otherwise finishImport's snapshot holds them.
if (this.phase !== 'ready') {
this.write({ t: 'e', u: p.url, ti: e.title, vc: visits, tc: typed, fv: first, v: fresh })
}
changed++
}
if (changed) this.changed()
return changed
}

/** After `importPages`: keeps the newest MAX_ENTRIES entries and writes them as a snapshot. */
async finishImport(): Promise<void> {
this.trim()
await this.idle()
await this.compact()
}

/** Forgets everything and removes the files (the profile was deleted). */
async drop(): Promise<void> {
this.dropped = true
Expand Down
Loading