fix(rules): make list order the single source of priority#12
Merged
Conversation
Two priority sources used to coexist: a numeric `Rule.priority: i32` field and the position of each rule in `config.rules`. They drifted in practice — the GUI editor stamped 100 on every new rule, the CLI defaulted to 10, the demo used 10, drag-reorder restamped (N-idx)*10. A user with `lark→ask` + `github.com→Chrome` both at priority 100 hit a stable-sort tie that silently favored whichever rule was added first, masking what felt like a working list order. Collapse the two sources into one: list order in `config.rules` IS priority, top of list wins. Duplicates are now structurally impossible. - core/rules: drop `Rule.priority` - core/routing: walk rules in declared order, return the first matching enabled rule (no sort) — plus a regression test that proves swapping list positions flips the winner - core/config/store: one-shot migration on load — if any rule on disk still has a legacy `priority` field, sort the array by it desc (stable) and strip the field, then persist - cli: drop `--priority` from `rules add`, replace `set-priority` with `rules move <id> <top|up|down|bottom>`, render `rules list` with 1-based slot numbers instead of a priority column - frontend: drop the Priority input from the rule editor, show the rule's slot (`#N`) instead of its old numeric priority, simplify drag-reorder to a pure list rewrite, and prepend onboarding templates so the first one ends up at slot #1 - config-dsl: drop the `.priority()` builder + the `priority` field from `RuleJson`; document that array order is authoritative
When a rule targets a browser that's missing or mistyped, the launcher
returns `PlatformError::Other("browser 'foo' is not installed on this
Mac")`. The dispatcher used to surface that as `LaunchOutcome::Failed`
and log it — from the user's perspective: click a link, nothing
happens.
Wrap the launcher call in `open_with_default_fallback`: on a failure
that names a browser id different from the config's `default_target`,
retry once with `default_target`. If primary and default share a
browser id (e.g. user broke their default too), skip the retry rather
than thrash the same missing binary. Both failure cases compose into
one error string so logs / Tauri callers still see what went wrong.
Applies to both the `Open` arm and the `Ask` arm (after the picker
resolves to a target), so a stale picker pick gets the same safety
net.
Adds four `dispatch::tests` cases against a scripted launcher: the
happy path, the fallback success path, the both-failed path, and the
"primary == default, no point retrying" path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
LinkPilot was carrying two competing notions of "priority":
Rule.priority: i32field.config.rules.They drifted apart in practice — the GUI editor stamped
100on every new rule, the CLI defaulted to10, the demo config used10, and drag-to-reorder restamped(N - idx) * 10. So a user with asource-app = Lark → Askrule and aurl-host = github.com → Open Chromerule could easily end up with both at the same numeric priority. The stable sort then silently favored whichever rule was added first; the picker forAskdid pop but the user, expectinggithub.com → Chrometo dispatch directly, read it as "nothing happened — priority isn't working."User report (translated): "当我的 rules 存在冲突的时候,例如我配置了 lark 打开使用 ask,同时配置了 github.com 默认走 Chrome,此时我在 lark 中点击 github 相关的域名不会有任何的浏览器跳转反应... 而且不能出现重复的优先级,应该按照列表排序的方式来规定优先级".
What
Collapse the two sources into one: list order in
config.rulesIS priority. Top of list wins. Duplicates are now structurally impossible.Core (
crates/core/)Rule.priorityfrom the schema.Router::evaluate_explainedwalksconfig.rulestop-to-bottom and returns the first matching enabled rule — no sort.ConfigStore::load_or_init: if a pre-v0.2 config still haspriorityfields on rules, stable-sort byprioritydescending (preserving user intent), strip the field, and persist atomically. The fsnotify external-edit path runs the same migration so hand-edited legacy configs still work.CLI (
crates/cli/)rules adddrops--priority; new rules append at the bottom (safe default — won't silently override existing ones).set-priorityremoved; replaced byrules move <id> <top|up|down|bottom>.rules listrenders 1-based slot numbers instead of a priority column.Frontend (
apps/desktop/src/)RuleEditorremoves the Priority input and explains that priority is now set by list order on the Rules page.pages/rules.tsxdrops the sort + restamp; drag-reorder is a pure list rewrite. The leading#Nbadge is now the rule's 1-based global slot.pages/inspector.tsx,pages/test-url.tsx,pages/workspace.tsx: same — show the slot, not a numeric priority.OnboardingFlowprepends toggled templates in their declared order so the first template lands at slot chore(deps): bump softprops/action-gh-release from 2 to 3 #1.DSL (
packages/config-dsl/).priority(p)builder removed;RuleJson.priorityremoved from the wire shape.compile()documents thatcfg.rulesorder is authoritative.Tests
routing::tests::first_match_in_list_order_wins— reproduces the user's exact scenario (Lark/Ask + github/Chrome) and asserts that swapping the list positions flips the winning decision.config::store::tests::migrates_legacy_priority_to_list_order— writes a legacy JSON with"priority": 10and"priority": 100rules in the wrong order, loads it, verifies the prio-100 rule moves to slot 1 and that theprioritykey is gone from disk after persistence.rule array order is preserved (list-order priority)plus an updatedRouteBuilder modifiers stickthat asserts nopriorityfield is emitted.Local verification
cargo test -p linkpilot-core— 32 passed (incl. the two new tests above)cargo test -p linkpilot-cli— 7 passedcargo check --workspaceincl.linkpilot-desktop— cleancargo clippy --workspace— no new warnings (only pre-existingtray.rsneedless_borrows)npm run buildinapps/desktop(tsc + vite) — cleanbun testinpackages/config-dsl— 13 passednpm run bundle:mac— producesLinkPilot.app+LinkPilot_0.2.0_aarch64.dmg; Info.plist patch applies cleanlyBackwards compatibility
The Rule struct doesn't use
serde(deny_unknown_fields), so existing configs that still carryprioritydeserialize without error. The migration runs on the next load — sorts byprioritydesc (stable), drops the field, persists once. Idempotent: subsequent loads see nopriorityand skip migration. User-authored priority intent is preserved.Test plan
linkpilot.config.jsonno longer contains"priority":after the daemon boots once.source-app = Lark → Askandurl-host = github.com → Open Chrome; drag the github rule to the top in the Rules page; click a github link inside Lark → opens directly in Chrome with no picker.lpt rules listprints rules in list order with#Nslots.lpt rules move <github-id> topraises that rule to slot 1.🤖 Generated with Claude Code