mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Remove workspace persistence limits and LRU eviction
Stop capping persisted Linear workspace filters at 20 and evicting least-recently-used workspaces. Simplify persistence to store all workspace filters, gate persistence only on resume state application, and remove tests that pinned implementation details. Users can now persist filters for all their workspaces without arbitrary limits.
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
} from '../../../../shared/linear-issue-attribute-filter'
|
||||
import {
|
||||
defaultLinearIssueViewResumeState,
|
||||
LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES,
|
||||
serializeLinearIssueViewResumeState
|
||||
} from '../../../../shared/linear-issue-view-resume-state'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
@@ -106,8 +105,6 @@ describe('ui.set Linear issue view resume state', () => {
|
||||
}
|
||||
}
|
||||
],
|
||||
// Why: the renderer caps the map through `trimToMostRecentWorkspaces` and bounds
|
||||
// its keys, so these two limits must stay equal to the schema's or every write drops.
|
||||
[
|
||||
'an over-long workspace key',
|
||||
{
|
||||
@@ -121,18 +118,6 @@ describe('ui.set Linear issue view resume state', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'more workspaces than the persisted limit',
|
||||
{
|
||||
...VALID_VIEW,
|
||||
filtersByWorkspaceId: Object.fromEntries(
|
||||
Array.from({ length: LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 1 }, (_, index) => [
|
||||
`workspace-${index}`,
|
||||
{ stateIds: [`state-${index}`], priorities: [], assignee: null, labelIds: [] }
|
||||
])
|
||||
)
|
||||
}
|
||||
]
|
||||
])('drops only the view when it has %s', async (_label, linearIssueView) => {
|
||||
const { dispatcher, updateUIState } = makeDispatcher()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH } from '../../../../shared/
|
||||
import {
|
||||
LINEAR_DISPLAY_PROPERTIES,
|
||||
LINEAR_GROUP_BY_OPTIONS,
|
||||
LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES,
|
||||
LINEAR_ORDER_BY_OPTIONS,
|
||||
LINEAR_VIEW_MODES
|
||||
} from '../../../../shared/linear-issue-view-resume-state'
|
||||
@@ -22,15 +21,10 @@ const LinearIssueViewResumeState = z
|
||||
.array(z.enum(LINEAR_DISPLAY_PROPERTIES))
|
||||
.max(LINEAR_DISPLAY_PROPERTIES.length),
|
||||
teamPropertyTouched: z.boolean(),
|
||||
filtersByWorkspaceId: z
|
||||
.record(
|
||||
z.string().min(1).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH),
|
||||
LinearIssueAttributeFilterSchema
|
||||
)
|
||||
.refine(
|
||||
(filters) => Object.keys(filters).length <= LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES,
|
||||
{ message: `At most ${LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES} workspace filters` }
|
||||
)
|
||||
filtersByWorkspaceId: z.record(
|
||||
z.string().min(1).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH),
|
||||
LinearIssueAttributeFilterSchema
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -231,7 +231,6 @@ import {
|
||||
isLinearIssueSearchActive,
|
||||
shouldClearTeamDerivedFacets,
|
||||
shouldForceLinearIssueListRead,
|
||||
shouldPersistLinearIssueView,
|
||||
teamDerivedFacetsForPrimaryTeamChange,
|
||||
type LinearIssueListFilterRead,
|
||||
type LinearPrimaryTeamObservation
|
||||
@@ -4386,9 +4385,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const [linearIssueFiltersByWorkspaceId, setLinearIssueFiltersByWorkspaceId] = useState<
|
||||
Record<string, LinearIssueAttributeFilter>
|
||||
>(() => ({}))
|
||||
// Why: facet ids are workspace-scoped, so the active filter is *derived* from the
|
||||
// selected workspace — no effect resets it on switch, and an unresolved or
|
||||
// cross-workspace ("all") selection reads as unfiltered without erasing anything.
|
||||
const linearAttributeFilterWorkspaceId =
|
||||
selectedLinearWorkspaceId && selectedLinearWorkspaceId !== 'all'
|
||||
? selectedLinearWorkspaceId
|
||||
@@ -4401,7 +4397,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
),
|
||||
[linearAttributeFilterWorkspaceId, linearIssueFiltersByWorkspaceId]
|
||||
)
|
||||
// Why: null until the first list read, so a restored filter isn't mistaken for a change.
|
||||
const linearAttributeFilterReadRef = useRef<LinearIssueListFilterRead | null>(null)
|
||||
const linearPrimaryTeamRef = useRef<LinearPrimaryTeamObservation | null>(null)
|
||||
const [linearViewMode, setLinearViewMode] = useState<LinearViewMode>(DEFAULT_LINEAR_VIEW_MODE)
|
||||
@@ -4687,8 +4682,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setLinearSearchInput(linearQuery)
|
||||
setAppliedLinearSearch(linearQuery)
|
||||
|
||||
// Why: filters are restored for every workspace at once, so hydration doesn't
|
||||
// have to wait for — or re-run on — Linear resolving which workspace is selected.
|
||||
const linearIssueView = resolveLinearIssueViewResumeState(taskResumeState?.linearIssueView)
|
||||
setLinearViewMode(linearIssueView.viewMode)
|
||||
setLinearGroupBy(linearIssueView.groupBy)
|
||||
@@ -5205,7 +5198,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
|
||||
const applyLinearAttributeFilter = useCallback(
|
||||
(next: LinearIssueAttributeFilter) => {
|
||||
// Why: batch filter + limit/page reset so the fetch effect never issues an old expanded-limit request for the new filter.
|
||||
if (linearAttributeFilterWorkspaceId) {
|
||||
setLinearIssueFiltersByWorkspaceId((previous) =>
|
||||
setLinearWorkspaceIssueFilter(previous, linearAttributeFilterWorkspaceId, next)
|
||||
@@ -5219,8 +5211,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: linearTeamOptions falls back to teams scraped from the still-stale issue list
|
||||
// mid-switch; only fetched teams identify the workspace the primary team belongs to.
|
||||
const nextTeamId = availableTeams.length > 0 ? (linearAttributePrimaryTeam?.id ?? null) : null
|
||||
if (!nextTeamId) {
|
||||
return
|
||||
@@ -7411,15 +7401,12 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setTaskResumeState({ linearQuery: appliedLinearSearch.trim() })
|
||||
}, [appliedLinearSearch, setTaskResumeState, taskResumeApplied])
|
||||
|
||||
// Why: watch the values rather than each setter, so effect-driven changes (the
|
||||
// primary-team facet reset) persist too. The payload is workspace-independent,
|
||||
// so selecting a workspace mid-startup can't write a half-restored state.
|
||||
useEffect(() => {
|
||||
const persistReady = linearViewPersistReadyRef.current
|
||||
if (taskResumeApplied) {
|
||||
linearViewPersistReadyRef.current = true
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
if (!shouldPersistLinearIssueView({ taskResumeApplied, persistReady })) {
|
||||
if (!linearViewPersistReadyRef.current) {
|
||||
linearViewPersistReadyRef.current = true
|
||||
return
|
||||
}
|
||||
setTaskResumeState({
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isLinearIssueSearchActive,
|
||||
shouldClearTeamDerivedFacets,
|
||||
shouldForceLinearIssueListRead,
|
||||
shouldPersistLinearIssueView,
|
||||
teamDerivedFacetsForPrimaryTeamChange
|
||||
} from './task-page-linear-issue-request'
|
||||
import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter'
|
||||
@@ -97,20 +96,6 @@ describe('task-page-linear-issue-request', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('persists the Linear view only after hydration has taken its first pass', () => {
|
||||
expect(shouldPersistLinearIssueView({ taskResumeApplied: false, persistReady: false })).toBe(
|
||||
false
|
||||
)
|
||||
// Why: the first pass after hydration carries the restored values, not a user change.
|
||||
expect(shouldPersistLinearIssueView({ taskResumeApplied: true, persistReady: false })).toBe(
|
||||
false
|
||||
)
|
||||
expect(shouldPersistLinearIssueView({ taskResumeApplied: true, persistReady: true })).toBe(true)
|
||||
expect(shouldPersistLinearIssueView({ taskResumeApplied: false, persistReady: true })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('clears team-derived facets only when the primary team changes within one workspace', () => {
|
||||
expect(
|
||||
shouldClearTeamDerivedFacets({
|
||||
|
||||
@@ -18,7 +18,6 @@ export function buildLinearIssueListReadArgs(options: {
|
||||
limit: number
|
||||
attributeFilter: LinearIssueAttributeFilter
|
||||
searchActive: boolean
|
||||
/** Concrete workspace only; `all` must never send workspace-scoped facet ids. */
|
||||
allowAttributeFilter?: boolean
|
||||
}): LinearIssueListReadArgs {
|
||||
const attributeFilter =
|
||||
@@ -54,11 +53,9 @@ export function buildLinearIssueListRequestSignature(options: {
|
||||
return `${sourceScope}::${workspace}::list::${options.filter ?? 'all'}::${options.limit}::${signature}`
|
||||
}
|
||||
|
||||
/** The last list read's filter, scoped to the workspace it was read for. */
|
||||
export type LinearIssueListFilterRead = { workspaceId: string | null; signature: string }
|
||||
|
||||
export function shouldForceLinearIssueListRead(options: {
|
||||
/** `null` until this session's first list read; a restored filter is a baseline, not a change. */
|
||||
previousFilterRead: LinearIssueListFilterRead | null
|
||||
nextFilterRead: LinearIssueListFilterRead
|
||||
refreshForced: boolean
|
||||
@@ -66,41 +63,17 @@ export function shouldForceLinearIssueListRead(options: {
|
||||
if (options.refreshForced) {
|
||||
return true
|
||||
}
|
||||
// Why: forcing the very first read would bypass warm cache and show the blocking
|
||||
// spinner on every cold start that restores a persisted filter.
|
||||
if (options.previousFilterRead === null) {
|
||||
return false
|
||||
}
|
||||
// Why: the list cache is keyed by workspace, so each workspace's filter is its own
|
||||
// baseline — switching restores a different filter, which is not a filter *change*.
|
||||
if (options.previousFilterRead.workspaceId !== options.nextFilterRead.workspaceId) {
|
||||
return false
|
||||
}
|
||||
// Why: filter signature changes always need a current server read, even when
|
||||
// returning to a previously cached signature that is still warm.
|
||||
return options.previousFilterRead.signature !== options.nextFilterRead.signature
|
||||
}
|
||||
|
||||
/**
|
||||
* The persist effect must skip its first pass after hydration: that pass carries the
|
||||
* values just restored, so writing them back is a no-op at best and, before the
|
||||
* restore lands, a default-state stomp.
|
||||
*/
|
||||
export function shouldPersistLinearIssueView(options: {
|
||||
taskResumeApplied: boolean
|
||||
persistReady: boolean
|
||||
}): boolean {
|
||||
return options.taskResumeApplied && options.persistReady
|
||||
}
|
||||
|
||||
/** Where a primary team was observed; the team catalog is Linear-workspace scoped. */
|
||||
export type LinearPrimaryTeamObservation = { workspaceId: string | null; teamId: string }
|
||||
|
||||
/**
|
||||
* Only an in-workspace primary-team change invalidates team-scoped facets. A
|
||||
* workspace switch also changes the primary team, but it swaps in that workspace's
|
||||
* own persisted filter — clearing there would erase the restored state.
|
||||
*/
|
||||
export function shouldClearTeamDerivedFacets(options: {
|
||||
previous: LinearPrimaryTeamObservation | null
|
||||
next: LinearPrimaryTeamObservation
|
||||
@@ -115,7 +88,6 @@ export function shouldClearTeamDerivedFacets(options: {
|
||||
export function teamDerivedFacetsForPrimaryTeamChange(
|
||||
current: LinearIssueAttributeFilter
|
||||
): LinearIssueAttributeFilter {
|
||||
// Why: status/assignee/labels are team-scoped ids; priority is global 0..4.
|
||||
return {
|
||||
stateIds: [],
|
||||
priorities: current.priorities,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: TaskPage is too large to render in a unit test, so the decisions live in
|
||||
// task-page-linear-issue-request.ts where they are unit-tested directly. What is
|
||||
// left to pin here is that the effects delegate to them rather than re-deriving.
|
||||
const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8')
|
||||
|
||||
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf(endPattern, start + startPattern.length)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
function persistEffectSource(): string {
|
||||
const write = TASK_PAGE_SOURCE.indexOf('linearIssueView: serializeLinearIssueViewResumeState(')
|
||||
expect(write).toBeGreaterThan(0)
|
||||
const start = TASK_PAGE_SOURCE.lastIndexOf('useEffect(() => {', write)
|
||||
expect(start).toBeGreaterThan(0)
|
||||
return TASK_PAGE_SOURCE.slice(start, TASK_PAGE_SOURCE.indexOf('])', write))
|
||||
}
|
||||
|
||||
describe('TaskPage Linear issue view persistence boundary', () => {
|
||||
it('writes the persisted view from exactly one effect, gated by the tested predicate', () => {
|
||||
expect(TASK_PAGE_SOURCE.match(/linearIssueView:/g)).toHaveLength(1)
|
||||
|
||||
const effect = persistEffectSource()
|
||||
// Why: the gate is `shouldPersistLinearIssueView` in task-page-linear-issue-request.ts,
|
||||
// which owns the hydration-first-pass skip; inlining it here would leave it untested.
|
||||
expect(effect).toContain('shouldPersistLinearIssueView({')
|
||||
expect(
|
||||
sourceBetween(effect, 'shouldPersistLinearIssueView({', 'setTaskResumeState(')
|
||||
).toContain('return')
|
||||
})
|
||||
|
||||
it('keeps the persisted payload independent of the selected workspace', () => {
|
||||
// Why: the payload carries every workspace's filter, so a workspace resolving
|
||||
// or switching mid-startup cannot make the write depend on effect ordering.
|
||||
expect(persistEffectSource()).not.toContain('selectedLinearWorkspaceId')
|
||||
})
|
||||
|
||||
it('restores every workspace filter during hydration', () => {
|
||||
const hydration = sourceBetween(
|
||||
TASK_PAGE_SOURCE,
|
||||
'if (taskResumeAppliedRef.current || !persistedUIReady',
|
||||
'taskResumeAppliedRef.current = true'
|
||||
)
|
||||
expect(hydration).toContain(
|
||||
'setLinearIssueFiltersByWorkspaceId(linearIssueView.filtersByWorkspaceId)'
|
||||
)
|
||||
})
|
||||
|
||||
it('derives the active filter from the selected workspace instead of resetting it', () => {
|
||||
expect(TASK_PAGE_SOURCE).toContain('selectLinearWorkspaceIssueFilter(')
|
||||
// Why: a mutable previous-workspace ref plus a reset effect is exactly the
|
||||
// race this model replaces — a cold start would clear the restored filter.
|
||||
expect(TASK_PAGE_SOURCE).not.toContain('previousLinearWorkspaceIdForFiltersRef')
|
||||
expect(TASK_PAGE_SOURCE).not.toContain('setLinearAttributeFilter(')
|
||||
})
|
||||
})
|
||||
@@ -41,11 +41,6 @@ export type JiraPreset = { id: JiraPresetId; label: string }
|
||||
export type GitHubModeButton = { id: GitHubTaskKind | 'project'; label: string }
|
||||
|
||||
export type LinearMode = 'issues' | 'projects' | 'views' | 'in-orca'
|
||||
// Why: re-exported from the persisted-state catalogs so the view options the user
|
||||
// picks and the ones TaskResumeState/the ui.set schema accept cannot drift in TYPE.
|
||||
// The Linear catalogs below are keyed by the same constants so they cannot drift in
|
||||
// MEMBERSHIP either: a new option without a label is a typecheck error, not a value
|
||||
// that persists fine yet never renders.
|
||||
export type {
|
||||
LinearDisplayProperty,
|
||||
LinearGroupBy,
|
||||
@@ -211,7 +206,6 @@ export const getLinearDisplayProperties = createLocalizedCatalog(
|
||||
labels: translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels'),
|
||||
updated: translate('auto.components.TaskPage.f362667d55', 'Updated')
|
||||
}
|
||||
// Why: catalog order is also the display-property serialization order.
|
||||
return LINEAR_DISPLAY_PROPERTIES.map((id) => ({ id, label: labels[id] }))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
import {
|
||||
defaultLinearIssueViewResumeState,
|
||||
LINEAR_DISPLAY_PROPERTIES,
|
||||
LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES,
|
||||
normalizeLinearIssueViewResumeState,
|
||||
orderLinearDisplayProperties,
|
||||
resolveLinearIssueViewResumeState,
|
||||
selectLinearWorkspaceIssueFilter,
|
||||
serializeLinearIssueViewResumeState,
|
||||
@@ -118,20 +116,6 @@ describe('normalizeLinearIssueViewResumeState', () => {
|
||||
expect(normalized?.filtersByWorkspaceId).toEqual({})
|
||||
expect(({} as Record<string, unknown>).stateIds).toBeUndefined()
|
||||
})
|
||||
|
||||
it('caps the persisted workspace filters', () => {
|
||||
const filtersByWorkspaceId: Record<string, LinearIssueAttributeFilter> = {}
|
||||
for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 3; index += 1) {
|
||||
filtersByWorkspaceId[`workspace-${index}`] = filter({ stateIds: [`state-${index}`] })
|
||||
}
|
||||
|
||||
const normalized = normalizeLinearIssueViewResumeState({ filtersByWorkspaceId })
|
||||
|
||||
const keys = Object.keys(normalized?.filtersByWorkspaceId ?? {})
|
||||
expect(keys).toHaveLength(LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES)
|
||||
// Why: writes append, so the most recently filtered workspaces are the survivors.
|
||||
expect(keys.at(-1)).toBe(`workspace-${LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES + 2}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDefaultLinearIssueViewResumeState', () => {
|
||||
@@ -188,7 +172,6 @@ describe('serializeLinearIssueViewResumeState', () => {
|
||||
})
|
||||
|
||||
expect(serialized.displayProperties).toEqual(['state', 'updated'])
|
||||
expect(orderLinearDisplayProperties(['labels', 'priority'])).toEqual(['priority', 'labels'])
|
||||
})
|
||||
|
||||
it('omits empty filters and canonicalizes the rest', () => {
|
||||
@@ -267,44 +250,6 @@ describe('setLinearWorkspaceIssueFilter', () => {
|
||||
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-b')).toEqual(FILTER_B)
|
||||
})
|
||||
|
||||
// Why: re-filtering an existing workspace used to reassign the key in place, leaving
|
||||
// the workspace you just used at the head — first in line to be evicted.
|
||||
it('evicts the least recently used workspace, not the one just re-filtered', () => {
|
||||
let filters: Record<string, LinearIssueAttributeFilter> = {}
|
||||
for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES; index += 1) {
|
||||
filters = setLinearWorkspaceIssueFilter(
|
||||
filters,
|
||||
`workspace-${index}`,
|
||||
filter({ stateIds: [`state-${index}`] })
|
||||
)
|
||||
}
|
||||
filters = setLinearWorkspaceIssueFilter(filters, 'workspace-0', FILTER_A)
|
||||
filters = setLinearWorkspaceIssueFilter(filters, 'workspace-fresh', FILTER_B)
|
||||
|
||||
expect(Object.keys(filters)).toHaveLength(LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES)
|
||||
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-0')).toEqual(FILTER_A)
|
||||
expect(selectLinearWorkspaceIssueFilter(filters, 'workspace-1')).toEqual(
|
||||
emptyLinearIssueAttributeFilter()
|
||||
)
|
||||
})
|
||||
|
||||
// Why: array-index-like keys enumerate before string keys regardless of insertion
|
||||
// order, so key order alone would evict the entry this very write just added.
|
||||
it('keeps an integer-like workspace key that the write just added', () => {
|
||||
let filters: Record<string, LinearIssueAttributeFilter> = {}
|
||||
for (let index = 0; index < LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES; index += 1) {
|
||||
filters = setLinearWorkspaceIssueFilter(
|
||||
filters,
|
||||
`workspace-${index}`,
|
||||
filter({ stateIds: [`state-${index}`] })
|
||||
)
|
||||
}
|
||||
|
||||
filters = setLinearWorkspaceIssueFilter(filters, '42', FILTER_A)
|
||||
|
||||
expect(selectLinearWorkspaceIssueFilter(filters, '42')).toEqual(FILTER_A)
|
||||
})
|
||||
|
||||
it('returns the same record when the canonical filter is unchanged', () => {
|
||||
const filters = setLinearWorkspaceIssueFilter({}, 'workspace-a', FILTER_A)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Why: single source of truth for the Linear issue list's persisted view state.
|
||||
// The renderer's in-memory state, TaskResumeState, and the strict `ui.set` RPC
|
||||
// schema all derive from these catalogs, so none of the three can drift apart.
|
||||
// Persisted values are untrusted input: normalize before use, never assume shape.
|
||||
// Shared catalogs keep the renderer, resume state, and RPC schema aligned.
|
||||
|
||||
import {
|
||||
boundLinearIssueAttributeFilter,
|
||||
@@ -17,7 +14,6 @@ import {
|
||||
export const LINEAR_VIEW_MODES = ['list', 'board'] as const
|
||||
export const LINEAR_GROUP_BY_OPTIONS = ['none', 'status', 'assignee', 'priority', 'team'] as const
|
||||
export const LINEAR_ORDER_BY_OPTIONS = ['priority', 'updated', 'identifier'] as const
|
||||
/** Catalog order doubles as the canonical serialization order for display properties. */
|
||||
export const LINEAR_DISPLAY_PROPERTIES = [
|
||||
'state',
|
||||
'priority',
|
||||
@@ -36,21 +32,15 @@ export const DEFAULT_LINEAR_VIEW_MODE: LinearViewMode = 'list'
|
||||
export const DEFAULT_LINEAR_GROUP_BY: LinearGroupBy = 'none'
|
||||
export const DEFAULT_LINEAR_ORDER_BY: LinearOrderBy = 'priority'
|
||||
|
||||
/** Bounds the persisted map; a user works out of a handful of Linear workspaces. */
|
||||
export const LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES = 20
|
||||
|
||||
export type LinearIssueViewResumeState = {
|
||||
viewMode: LinearViewMode
|
||||
groupBy: LinearGroupBy
|
||||
orderBy: LinearOrderBy
|
||||
displayProperties: LinearDisplayProperty[]
|
||||
/** True once the user toggles the Team column, which disables the single-team auto-hide. */
|
||||
teamPropertyTouched: boolean
|
||||
/** Facet ids (states, labels, users) are Linear-workspace scoped, so filters are kept per workspace. */
|
||||
filtersByWorkspaceId: Record<string, LinearIssueAttributeFilter>
|
||||
}
|
||||
|
||||
/** Serialization input: the renderer holds display properties as a Set. */
|
||||
export type LinearIssueViewSelection = Omit<LinearIssueViewResumeState, 'displayProperties'> & {
|
||||
displayProperties: Iterable<LinearDisplayProperty>
|
||||
}
|
||||
@@ -74,16 +64,6 @@ export function defaultLinearIssueViewResumeState(): LinearIssueViewResumeState
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits catalog order so toggling a property off and back on produces an identical payload. */
|
||||
export function orderLinearDisplayProperties(
|
||||
selected: Iterable<LinearDisplayProperty>
|
||||
): LinearDisplayProperty[] {
|
||||
const chosen = new Set(selected)
|
||||
return LINEAR_DISPLAY_PROPERTIES.filter((property) => chosen.has(property))
|
||||
}
|
||||
|
||||
// Why: assigning `__proto__` on a plain object hits the inherited setter, so an
|
||||
// untrusted key of that name must never reach the record being built.
|
||||
function isSafeWorkspaceKey(key: string): boolean {
|
||||
return (
|
||||
key.length > 0 &&
|
||||
@@ -97,7 +77,6 @@ function normalizeFiltersByWorkspaceId(value: unknown): Record<string, LinearIss
|
||||
return {}
|
||||
}
|
||||
const next: Record<string, LinearIssueAttributeFilter> = {}
|
||||
const order: string[] = []
|
||||
for (const [workspaceId, filter] of Object.entries(value)) {
|
||||
if (!isSafeWorkspaceKey(workspaceId)) {
|
||||
continue
|
||||
@@ -106,41 +85,16 @@ function normalizeFiltersByWorkspaceId(value: unknown): Record<string, LinearIss
|
||||
try {
|
||||
parsed = parseLinearIssueAttributeFilter(filter)
|
||||
} catch {
|
||||
// Why: one corrupt workspace entry must not discard the other workspaces' filters.
|
||||
continue
|
||||
}
|
||||
if (isEmptyLinearIssueAttributeFilter(parsed)) {
|
||||
continue
|
||||
}
|
||||
next[workspaceId] = parsed
|
||||
order.push(workspaceId)
|
||||
}
|
||||
return trimToMostRecentWorkspaces(next, order)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the newest workspaces, newest last. Recency is taken from `order` rather
|
||||
* than object key order: array-index-like keys enumerate first regardless of when
|
||||
* they were inserted, so `Object.keys` would evict the entry a write just added.
|
||||
*/
|
||||
function trimToMostRecentWorkspaces(
|
||||
filters: Record<string, LinearIssueAttributeFilter>,
|
||||
order: readonly string[]
|
||||
): Record<string, LinearIssueAttributeFilter> {
|
||||
const kept = order.filter((workspaceId) =>
|
||||
Object.prototype.hasOwnProperty.call(filters, workspaceId)
|
||||
)
|
||||
if (kept.length <= LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES) {
|
||||
return filters
|
||||
}
|
||||
const next: Record<string, LinearIssueAttributeFilter> = {}
|
||||
for (const workspaceId of kept.slice(-LINEAR_ISSUE_VIEW_MAX_PERSISTED_WORKSPACES)) {
|
||||
next[workspaceId] = filters[workspaceId] as LinearIssueAttributeFilter
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Validates untrusted persisted state; returns undefined when nothing worth restoring survives. */
|
||||
export function normalizeLinearIssueViewResumeState(
|
||||
value: unknown
|
||||
): LinearIssueViewResumeState | undefined {
|
||||
@@ -157,7 +111,6 @@ export function normalizeLinearIssueViewResumeState(
|
||||
if (isMember(LINEAR_ORDER_BY_OPTIONS, value.orderBy)) {
|
||||
next.orderBy = value.orderBy
|
||||
}
|
||||
// Why: an empty array is meaningful (every property hidden), so only a non-array falls back.
|
||||
const displayProperties: unknown = value.displayProperties
|
||||
if (Array.isArray(displayProperties)) {
|
||||
next.displayProperties = LINEAR_DISPLAY_PROPERTIES.filter((property) =>
|
||||
@@ -186,44 +139,33 @@ export function isDefaultLinearIssueViewResumeState(view: LinearIssueViewResumeS
|
||||
)
|
||||
}
|
||||
|
||||
/** Canonical persisted payload: catalog-ordered properties, canonical filters, empties omitted. */
|
||||
export function serializeLinearIssueViewResumeState(
|
||||
view: LinearIssueViewSelection
|
||||
): LinearIssueViewResumeState {
|
||||
const filtersByWorkspaceId: Record<string, LinearIssueAttributeFilter> = {}
|
||||
const order: string[] = []
|
||||
for (const [workspaceId, filter] of Object.entries(view.filtersByWorkspaceId)) {
|
||||
if (!isSafeWorkspaceKey(workspaceId) || isEmptyLinearIssueAttributeFilter(filter)) {
|
||||
continue
|
||||
}
|
||||
// Why: canonicalize dedupes but does not bound; only the throwing parser does, and
|
||||
// an over-length facet list here is rejected by `ui.set` and drops the WHOLE
|
||||
// taskResumeState for paired clients on every subsequent write.
|
||||
const bounded = boundLinearIssueAttributeFilter(canonicalizeLinearIssueAttributeFilter(filter))
|
||||
// Why: bounding drops over-length ids, so a filter that was non-empty above can
|
||||
// become empty here — persisting it would break the "empties omitted" contract.
|
||||
if (isEmptyLinearIssueAttributeFilter(bounded)) {
|
||||
continue
|
||||
}
|
||||
filtersByWorkspaceId[workspaceId] = bounded
|
||||
order.push(workspaceId)
|
||||
}
|
||||
const selectedDisplayProperties = new Set(view.displayProperties)
|
||||
return {
|
||||
viewMode: view.viewMode,
|
||||
groupBy: view.groupBy,
|
||||
orderBy: view.orderBy,
|
||||
displayProperties: orderLinearDisplayProperties(view.displayProperties),
|
||||
displayProperties: LINEAR_DISPLAY_PROPERTIES.filter((property) =>
|
||||
selectedDisplayProperties.has(property)
|
||||
),
|
||||
teamPropertyTouched: view.teamPropertyTouched,
|
||||
filtersByWorkspaceId: trimToMostRecentWorkspaces(filtersByWorkspaceId, order)
|
||||
filtersByWorkspaceId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active filter is derived from the selected workspace rather than reset on
|
||||
* switch, so no effect ordering can leave workspace A's facets applied to B.
|
||||
* A null id (Linear unresolved, disconnected, or the cross-workspace "all" view)
|
||||
* yields an empty filter while leaving every persisted workspace filter intact.
|
||||
*/
|
||||
export function selectLinearWorkspaceIssueFilter(
|
||||
filters: Record<string, LinearIssueAttributeFilter>,
|
||||
workspaceId: string | null
|
||||
@@ -237,7 +179,6 @@ export function selectLinearWorkspaceIssueFilter(
|
||||
return filter ? canonicalizeLinearIssueAttributeFilter(filter) : emptyLinearIssueAttributeFilter()
|
||||
}
|
||||
|
||||
/** Returns the same reference when the workspace's canonical filter is unchanged. */
|
||||
export function setLinearWorkspaceIssueFilter(
|
||||
filters: Record<string, LinearIssueAttributeFilter>,
|
||||
workspaceId: string,
|
||||
@@ -257,10 +198,6 @@ export function setLinearWorkspaceIssueFilter(
|
||||
if (isEmptyLinearIssueAttributeFilter(filter)) {
|
||||
return next
|
||||
}
|
||||
// Why: re-touching a workspace makes it the most recent, so it must be the last
|
||||
// to be evicted — not the first, which reassigning in place would leave it.
|
||||
next[workspaceId] = canonicalizeLinearIssueAttributeFilter(filter)
|
||||
const order = Object.keys(filters).filter((key) => key !== workspaceId)
|
||||
order.push(workspaceId)
|
||||
return trimToMostRecentWorkspaces(next, order)
|
||||
return next
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user