Files
orca/src/shared/project-section-order.ts
T
Jinwoo HongandClaude 550cc5cdcc fix(sidebar): order sidebar names the same on desktop, phone and server (#29946)
* fix(sidebar): order sidebar text the same on every device

Sidebar names, project labels, project-group names and folder workspace
names were ordered with localeCompare, whose result depends on the ICU data
bundled with Electron, the phone WebView or Node. Order them with one
comparator in src/shared/sidebar-list/text-order.ts instead: case-folded and
NFC-normalized, digit runs by value, the rest by code point, and equal text
broken by host-qualified id.

Co-Authored-By: Claude <noreply@anthropic.com>

* perf(sidebar): fold each sort label once per sort; reuse the phone row identity helper

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sidebar): ignore accents when ordering sidebar names

Accents and case now only break exact ties, so "Éclair" sorts beside "eclair".

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-16 21:07:05 -07:00

203 lines
7.2 KiB
TypeScript

// Labels and order of the desktop sidebar's project sections, shared so the paired phone matches.
import type { ProjectSectionEntry, ProjectSectionRepo } from './project-section-grouping'
import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from './repo-display-labels'
import type { ProjectOrderBy } from './ui-chrome-types'
import { compareCodePoints, compareSidebarText } from './sidebar-list/text-order'
export type ActivityItem = { lastActivityAt?: number }
// Why: disambiguate a section's *own* label, not its anchor checkout's repo
// name. Substituting repo.displayName made a project header read the project
// name while it was the only repo-backed section and the checkout name as soon
// as a second one rendered, so filtering workspaces renamed the project
// (#16127). Path suffixes still resolve genuinely identical labels.
export function withRepoSectionDisplayLabels<
E extends ProjectSectionEntry<ActivityItem, ProjectSectionRepo>
>(entries: readonly [string, E][]): [string, E][] {
const labelItems = entries.flatMap(([, group]) =>
group.repo ? [{ ...group.repo, displayName: group.label }] : []
)
if (labelItems.length === 0) {
return [...entries]
}
const labelsByPath = getRepoDisplayLabelsByPath(labelItems)
return entries.map(([key, group]) => [
key,
group.repo
? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label }
: group
])
}
/**
* Recent rank for a project header. `hasActivity` projects (at least one
* visible worktree) always sort before fallback projects, regardless of the
* numeric values — a placeholder's `addedAt` must never outrank real activity.
* Within each tier, higher timestamps come first.
*/
type RecentRank = { hasActivity: boolean; ts: number }
export function recentRankForEntry(
entry: readonly [string, ProjectSectionEntry<ActivityItem, ProjectSectionRepo>]
): RecentRank {
let max = Number.NEGATIVE_INFINITY
for (const worktree of entry[1].activityItems ?? entry[1].items) {
// Why: phone rows from older hosts may omit the timestamp; the desktop's always carry one.
if ((worktree.lastActivityAt ?? Number.NEGATIVE_INFINITY) > max) {
max = worktree.lastActivityAt ?? max
}
}
if (max !== Number.NEGATIVE_INFINITY) {
// Why: Recent must be timestamp-based, not encounter order — the incoming
// array is no longer pre-sorted by recency once decoupled from sortBy.
return { hasActivity: true, ts: max }
}
const addedAt = entry[1].repo?.addedAt
return {
hasActivity: false,
ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY
}
}
export function createRecentRankLookup<
T extends readonly [string, ProjectSectionEntry<ActivityItem, ProjectSectionRepo>]
>(): (entry: T) => RecentRank {
const ranks = new Map<T, RecentRank>()
return (entry) => {
const existing = ranks.get(entry)
if (existing) {
return existing
}
const rank = recentRankForEntry(entry)
ranks.set(entry, rank)
return rank
}
}
export function compareRecentRank(a: RecentRank, b: RecentRank): number {
if (a.hasActivity !== b.hasActivity) {
return a.hasActivity ? -1 : 1
}
return b.ts - a.ts
}
function manualRankForEntry(
entry: readonly [string, ProjectSectionEntry<ActivityItem, ProjectSectionRepo>],
repoOrder: ReadonlyMap<string, number> | undefined
): number {
const key = entry[0]
const repoIds =
entry[1].repoIds.size > 0
? [...entry[1].repoIds]
: [key.startsWith('repo:') ? key.slice('repo:'.length) : key]
let rank = Number.POSITIVE_INFINITY
for (const repoId of repoIds) {
const repoRank = repoOrder?.get(repoId)
if (repoRank !== undefined && repoRank < rank) {
rank = repoRank
}
}
return rank
}
export function getManualOrderAnchorRepo<R extends ProjectSectionRepo>(
group: ProjectSectionEntry<unknown, R>,
repoMap: ReadonlyMap<string, R>,
repoOrder: ReadonlyMap<string, number> | undefined
): R | undefined {
let anchor = group.repo
let anchorRank = anchor ? (repoOrder?.get(anchor.id) ?? Number.POSITIVE_INFINITY) : undefined
for (const repoId of group.repoIds) {
const repo = repoMap.get(repoId)
if (!repo) {
continue
}
const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY
if (!anchor || rank < (anchorRank ?? Number.POSITIVE_INFINITY)) {
anchor = repo
anchorRank = rank
}
}
return anchor
}
/**
* Order project header entries by the user's project-order preference. Manual
* follows the canonical repoOrder; Recent follows each project's most recent
* visible workspace activity (descending), with empty/imported-only projects
* sorting after active ones, then by manual rank, then label.
*/
export function sortProjectEntries<
T extends readonly [string, ProjectSectionEntry<ActivityItem, ProjectSectionRepo>]
>(
entries: T[],
projectOrderBy: ProjectOrderBy,
repoOrder: ReadonlyMap<string, number> | undefined
): T[] {
if (projectOrderBy === 'recent') {
if (entries.length < 2) {
return [...entries]
}
const getRecentRank = createRecentRankLookup<T>()
return [...entries].sort((a, b) => {
const byRecent = compareRecentRank(getRecentRank(a), getRecentRank(b))
if (byRecent !== 0) {
return byRecent
}
const ma = manualRankForEntry(a, repoOrder)
const mb = manualRankForEntry(b, repoOrder)
if (ma !== mb) {
return ma - mb
}
return compareSidebarText(a[1].label, b[1].label) || compareCodePoints(a[0], b[0])
})
}
if (!repoOrder) {
return entries
}
return [...entries].sort((a, b) => {
const ra = manualRankForEntry(a, repoOrder)
const rb = manualRankForEntry(b, repoOrder)
if (ra !== rb) {
return ra - rb
}
return compareSidebarText(a[1].label, b[1].label) || compareCodePoints(a[0], b[0])
})
}
/**
* Project sections in header order. Each header anchors on the repo that leads manual order, so
* drag and actions target the same persisted order source the row sorter reads.
*/
export function orderProjectSections<
R extends ProjectSectionRepo,
E extends ProjectSectionEntry<ActivityItem, R>
>(
sections: ReadonlyMap<string, E>,
repoMap: ReadonlyMap<string, R>,
repoOrder: ReadonlyMap<string, number> | undefined,
projectOrderBy: ProjectOrderBy
): [string, E][] {
for (const section of sections.values()) {
section.repo = getManualOrderAnchorRepo(section, repoMap, repoOrder)
}
// Why: project header order is its own user choice (projectOrderBy), decoupled from workspace
// sortBy. Manual uses the canonical repoOrder; Recent follows activity.
return sortProjectEntries(Array.from(sections.entries()), projectOrderBy, repoOrder)
}
/** Each repo id's rank in catalog order; the first occurrence wins across hosts. */
export function getLogicalRepoOrderRankById(
orderedRepoIds: readonly string[]
): Map<string, number> {
const rankById = new Map<string, number>()
orderedRepoIds.forEach((repoId, index) => {
// Why: paired hosts can contribute the same logical project ID; its merged
// header must anchor to the first occurrence instead of whichever host loaded last.
if (!rankById.has(repoId)) {
rankById.set(repoId, index)
}
})
return rankById
}