Skip to content

Recover wiped keyboard shortcuts: decode legacy StoredShortcut format (#5422) - #5423

Closed
austinywang wants to merge 2 commits into
mainfrom
issue-5422-shortcut-wipe-decode
Closed

austinywang wants to merge 2 commits into
mainfrom
issue-5422-shortcut-wipe-decode

Conversation

@austinywang

@austinywang austinywang commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5422.

Updating to v0.64.11+ silently reverted every customized keyboard shortcut to its default. The Settings SPM reimplement (#4975) changed StoredShortcut's persisted JSON from a flat shape to nested first/second strokes, with synthesized Codable and no legacy decoder. The storage key (shortcut.<action> in UserDefaults) is unchanged, so the new build reads each user's existing flat JSON, fails to decode it (keyNotFound("first")), swallows the error, and falls back to the default.

Fix

A custom StoredShortcut.init(from:) that decodes the nested shape and falls back to the legacy flat shape (mapping the flat key/command/…/keyCode to first and chord* to second). The user's data is still in UserDefaults under the same key, so this recovers it non-destructively — no migration write, and encoding stays the nested format. A missing key (neither shape) still throws so genuinely unrecognized data falls back to the default.

This one decoder fixes every read path: KeyboardShortcutSettingsLookup, and the package SettingCodable decodeFromUserDefaults / decodeFromJSON.

Two-commit red → green

  • Commit 1 adds the regression test. Decoding the real pre-0.64.11 flat JSON throws keyNotFound("first"), so the SettingCodable path returns nil. Red.
  • Commit 2 adds the decoder. All legacy fixtures (single stroke, chord, unbound) decode to the correct nested value, and the new nested format still round-trips. Green.

Verified locally with swift test --package-path Packages/CmuxSettings (4 legacy tests fail on commit 1, all 44 pass on commit 2).

The general guard going forward: a round-trip / legacy-decode test for every Codable type persisted in UserDefaults or cmux.json, so a future serialized-shape change can't silently wipe user data.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Cursor Bugbot is generating a summary for commit ac32c3a. Configure here.


Summary by cubic

Restores users’ customized keyboard shortcuts by decoding the legacy flat StoredShortcut format, so upgrades to v0.64.11+ no longer reset bindings to defaults. Data is recovered on read from the same UserDefaults keys; encoding stays in the new nested shape.

  • Bug Fixes
    • Added a custom StoredShortcut.init(from:) that decodes the nested first/second strokes and falls back to the legacy flat key/command/.../chord* fields; throws only if neither shape matches.
    • Added regression tests for legacy single-stroke, chord, and unbound values, plus round-trip of the new format; covers KeyboardShortcutSettingsLookup and SettingCodable decode paths.

Written for commit ac32c3a. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a data loss issue where keyboard shortcuts customized in earlier versions were not being properly loaded, causing them to revert to default values. All previously saved user shortcuts are now correctly preserved and restored across app updates.
  • Tests

    • Added backward compatibility tests validating the proper loading of keyboard shortcut configurations saved in previous application versions.

austinywang and others added 2 commits June 4, 2026 19:40
Decoding a pre-0.64.11 flat shortcut JSON into the new nested StoredShortcut throws keyNotFound('first'), so the SettingCodable path returns nil and the binding reverts to its default. This test captures that and fails until a legacy decoder is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a custom StoredShortcut.init(from:) that decodes the current nested first/second shape and falls back to the pre-0.64.11 flat shape (top-level key/command/.../chord* fields). The user's data is still present in UserDefaults under the same shortcut.<action> key, so this recovers it non-destructively; encoding stays the nested format. Fixes the regression where updating to v0.64.11+ silently reverted every customized keyboard shortcut to its default.

Closes #5422

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

vercel Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 5, 2026 3:02am
cmux-staging Building Building Preview, Comment Jun 5, 2026 3:02am

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

StoredShortcut implements a custom Codable decoder that supports both a new nested {first, second} shape and legacy flat on-disk format. Missing legacy modifiers are treated as false, and the second stroke is derived from the presence of chordKey. Tests validate decoding of legacy single-stroke, chord, and unbound bindings, plus recovery from user defaults and round-trip encoding.

Changes

Backward-compatible shortcut decoder

Layer / File(s) Summary
Custom decoder with legacy fallback
Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift
StoredShortcut defines CodingKeys for nested {first, second} shape and a custom init(from:) that attempts nested decoding first, then falls back to LegacyFlatShortcut which reconstructs ShortcutStroke values from legacy top-level modifier and key fields, treating missing fields as false and deriving secondStroke from chord* field presence.
Legacy decoding validation
Packages/CmuxSettings/Tests/CmuxSettingsTests/StoredShortcutLegacyDecodingTests.swift
Test suite validates backward compatibility with comprehensive cases covering legacy single-stroke, legacy chord, and unbound bindings from pre-0.64.11 JSON fixtures; verifies decodeFromUserDefaults recovery and confirms current nested format round-trips correctly.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

  • manaflow-ai/cmux#5422: This PR directly implements the backward-compatible init(from:) decoder and regression tests required to recover legacy flat StoredShortcut JSON on disk, preventing user-customized shortcuts from reverting to defaults after the nested shape format change.

Possibly related PRs

  • manaflow-ai/cmux#5143: Both PRs extend StoredShortcut decoding for the nested {first, second} shortcut object form; this PR adds legacy flat-format fallback compatibility, while the retrieved PR's settings-file parsing depends directly on the same StoredShortcut codable shape and behavior.

Poem

🐰 A shortcut was lost in the format shift,
But now we decode with a backward-compat gift,
Legacy strokes spring back to life,
No more lost bindings, no more strife!
Tests keep the future bright and true. ✨


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift File And Package Boundaries ❌ Error New file violates Packages/**/*.swift boundary rule: contains two meaningful types (StoredShortcut and LegacyFlatShortcut) instead of one per file. Extract LegacyFlatShortcut to LegacyFlatShortcut.swift to comply with "one type per file" guideline for Packages files.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: recovering wiped keyboard shortcuts by decoding legacy StoredShortcut format, with a reference to the issue number.
Description check ✅ Passed The description covers the main required sections: what changed and why (the regression and fix), testing approach (local verification with swift test), and checklist items are present.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Custom Decodable for StoredShortcut (Sendable value type) with immutable fields; no implicit MainActor or shared mutable state without isolation.
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization constructs (semaphores, sleeps, dispatch delays, polling, locks) introduced; only pure deterministic JSON decoding logic for backward compatibility.
Cmux No Hacky Sleeps ✅ Passed Check scoped to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts. PR contains only Swift code; check not applicable.
Cmux Algorithmic Complexity ✅ Passed The PR adds only a custom Codable decoder for StoredShortcut with fixed O(1) field decoding, no loops or collection scans, operating on tiny fixed-size keyboard bindings without hot-path violations.
Cmux Swift Concurrency ✅ Passed No legacy async patterns found. Changes are purely synchronous Codable decoding logic—no Dispatch queues, Combine, completion handlers, or fire-and-forget Tasks.
Cmux Swift @Concurrent ✅ Passed PR introduces only synchronous decoding/encoding code with no async operations, nonisolated async methods, or @concurrent annotations, so it complies with swift-concurrent-annotation.md rules.
Cmux Swift Logging ✅ Passed No logging violations found. StoredShortcut.swift contains no print, debugPrint, dump, NSLog, or Logger statements. Test file follows allowed pattern.
Cmux User-Facing Error Privacy ✅ Passed PR modifies only internal Codable infrastructure with no user-facing errors or privacy-sensitive information. GitHub URLs appear only in developer-only documentation comments as allowed.
Cmux Full Internationalization ✅ Passed No user-facing strings introduced; changes are internal Codable decoder and test fixtures, both exempt from i18n requirements.
Cmux Swiftui State Layout ✅ Passed PR contains only data model and test code with no SwiftUI changes. No @Observable, @Published, @State, GeometryReader, or store references in SwiftUI views.
Cmux Architecture Rethink ✅ Passed Pure function decoder with clear ownership and single implementation point; no timing, locks, observers, state splits, or side effects. Standard codecs with no architectural anti-patterns.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR changes only keyboard shortcut data serialization (StoredShortcut struct) and tests; contains no NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5422-shortcut-wipe-decode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a custom StoredShortcut.init(from:) that decodes the current nested first/second format and transparently falls back to the pre-0.64.11 flat key/chord* JSON, preventing upgraded users from silently losing customized keyboard shortcuts. A companion LegacyFlatShortcut private type handles the mapping; encoding continues to write the new nested format via the synthesized encode(to:).

  • The fallback decoder is activated when decodeIfPresent(.first) returns nil (key absent), then delegates to LegacyFlatShortcut which uses key as the discriminator — throwing if neither shape matches so genuinely corrupt data still reverts to the default.
  • Five new regression tests cover the legacy single-stroke, chord, unbound, decodeFromUserDefaults, and round-trip cases documented as red → green across two commits.

Confidence Score: 4/5

Safe to merge; the decoder logic is correct, the fallback path is well-guarded, and the regression tests cover the real pre-0.64.11 JSON fixtures end-to-end.

The custom decoder correctly distinguishes new from legacy JSON and the private LegacyFlatShortcut type is tightly scoped. The only forward-looking concern is that legacy data in UserDefaults is never rewritten to the new format, so the fallback decoder silently becomes a permanent dependency — a future cleanup that removes it would reproduce the original regression for any user who has not re-saved their shortcuts through the UI.

Both changed files are straightforward; StoredShortcut.swift warrants a second look around the migration-write question before the legacy decoder is ever removed.

Important Files Changed

Filename Overview
Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift Adds a custom init(from:) that decodes the new nested format first and falls back to the legacy flat format via LegacyFlatShortcut; encoding stays in the new nested format via the synthesized encode(to:). No migration write, so legacy data in UserDefaults persists in the old format indefinitely.
Packages/CmuxSettings/Tests/CmuxSettingsTests/StoredShortcutLegacyDecodingTests.swift New regression test suite covering legacy single-stroke, chord, unbound, the UserDefaults decode path, and a round-trip of the current nested format. Good coverage of the intended fix; the decodeFromJSON path is only implicitly covered.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["StoredShortcut.init(from: decoder)"] --> B["container.decodeIfPresent(.first)"]
    B -- "first key present & valid ShortcutStroke" --> C["Decode .second via decodeIfPresent (new nested format)"]
    B -- "first key absent or null" --> D["LegacyFlatShortcut(from: decoder)"]
    D --> E["decode 'key' (required discriminator)"]
    E -- "key present" --> F["Build firstStroke from key/command/shift/option/control/keyCode"]
    E -- "key absent → throws" --> G["Caller catches → falls back to action default binding"]
    F --> H["decodeIfPresent chordKey"]
    H -- "chordKey present & non-empty" --> I["Build secondStroke from chord* fields"]
    H -- "chordKey absent or empty" --> J["secondStroke = nil"]
    I --> K["StoredShortcut with chord"]
    J --> L["StoredShortcut single-stroke or unbound"]
    C --> M["StoredShortcut (new format)"]
Loading

Reviews (1): Last reviewed commit: "Recover legacy flat StoredShortcut bindi..." | Re-trigger Greptile

Comment on lines +40 to +47
if let first = try container.decodeIfPresent(ShortcutStroke.self, forKey: .first) {
self.first = first
self.second = try container.decodeIfPresent(ShortcutStroke.self, forKey: .second)
return
}
let legacy = try LegacyFlatShortcut(from: decoder)
self.first = legacy.firstStroke
self.second = legacy.secondStroke

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Legacy data in UserDefaults never migrated to the new format

Because decodeFromUserDefaults / decodeFromJSON both call try? and discard errors, and encodeForUserDefaults / encodeForJSON are only invoked when a setting is explicitly saved by the user, the legacy flat JSON will remain in UserDefaults indefinitely for users who never re-open and re-save their shortcuts. The legacy decoder will silently stay load-bearing forever; a future PR that removes it (reasonable cleanup) would silently revert those users' shortcuts again. Consider either (a) writing the decoded StoredShortcut back in the new format after a successful legacy decode, or (b) adding a doc comment on LegacyFlatShortcut that explicitly states it must be kept until a coordinated migration is shipped.

Comment on lines +57 to +65
@Test func newNestedFormatStillRoundTrips() throws {
// The legacy fallback must not regress the current nested format.
let original = StoredShortcut(
first: ShortcutStroke(key: "p", command: true, shift: true, keyCode: 35),
second: ShortcutStroke(key: "k", keyCode: 40)
)
let data = try JSONEncoder().encode(original)
#expect(try JSONDecoder().decode(StoredShortcut.self, from: data) == original)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 decodeFromJSON path not explicitly exercised

The PR description says "This one decoder fixes every read path: KeyboardShortcutSettingsLookup, and the package SettingCodable decodeFromUserDefaults / decodeFromJSON." The suite tests decodeFromUserDefaults directly but not decodeFromJSON. Both ultimately use JSONDecoder().decode(StoredShortcut.self, …), so the coverage is implicit, but a thin explicit test for decodeFromJSON with a legacy fixture would confirm the JSONSerialization → JSONDecoder round-trip and guard against a future refactor that diverges the two paths.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift`:
- Around line 32-38: Add DocC callouts to the public initializer
StoredShortcut.init(from:) so it documents the decoder parameter and possible
thrown errors; update the doc comment above `public init(from decoder: any
Decoder) throws` to include a `- Parameter decoder:` description explaining the
decoder input and a `- Throws:` description listing the conditions/errors that
can be thrown during decoding (e.g., invalid shape or missing required fields),
ensuring the public symbol meets the package documentation guideline.
- Around line 84-126: Move the private struct LegacyFlatShortcut (and its
init(from:) decoder logic that references ShortcutStroke) into a new file named
LegacyFlatShortcut.swift and remove the file-private visibility so the type is
internal (i.e., drop the leading "private") so StoredShortcut's init(from:) can
still decode using LegacyFlatShortcut; keep the same CodingKeys, Decodable
conformance, and behavior unchanged and run a build to ensure ShortcutStroke is
visible to the new file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 34277bb8-217e-42aa-b417-4a83beb74e10

📥 Commits

Reviewing files that changed from the base of the PR and between 087309a and ac32c3a.

📒 Files selected for processing (2)
  • Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift
  • Packages/CmuxSettings/Tests/CmuxSettingsTests/StoredShortcutLegacyDecodingTests.swift

Comment on lines +32 to +38
/// Decodes the current nested shape and transparently recovers the legacy
/// flat shape persisted by cmux ≤ 0.64.10 (top-level `key` / `command` / …
/// / `chord*` fields). Without this, every shortcut a user customized
/// before the move to nested ``ShortcutStroke``s fails to decode and
/// silently reverts to its default.
/// See https://github.com/manaflow-ai/cmux/issues/5422.
public init(from decoder: any Decoder) throws {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Complete DocC callouts for the new public decoder initializer.

public init(from:) has a good summary, but this package rule requires full DocC callouts (- Parameter decoder: and - Throws: at minimum).

Suggested doc update
 /// Decodes the current nested shape and transparently recovers the legacy
 /// flat shape persisted by cmux ≤ 0.64.10 (top-level `key` / `command` / …
 /// / `chord*` fields). Without this, every shortcut a user customized
 /// before the move to nested ``ShortcutStroke``s fails to decode and
 /// silently reverts to its default.
 /// See https://github.com/manaflow-ai/cmux/issues/5422.
+/// - Parameter decoder: The decoder containing either nested or legacy-flat shortcut data.
+/// - Throws: A decoding error when neither supported shape can be decoded.
 public init(from decoder: any Decoder) throws {

As per coding guidelines: “Every public symbol in any new Swift package under Packages/ must be documented … with parameter/returns/throws callouts.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift`
around lines 32 - 38, Add DocC callouts to the public initializer
StoredShortcut.init(from:) so it documents the decoder parameter and possible
thrown errors; update the doc comment above `public init(from decoder: any
Decoder) throws` to include a `- Parameter decoder:` description explaining the
decoder input and a `- Throws:` description listing the conditions/errors that
can be thrown during decoding (e.g., invalid shape or missing required fields),
ensuring the public symbol meets the package documentation guideline.

Comment on lines +84 to +126
/// The pre-0.64.11 on-disk shape of ``StoredShortcut``: the primary stroke's
/// fields flat at the top level plus optional `chord*` fields for a second
/// stroke. Decoded only as a fallback by ``StoredShortcut/init(from:)`` so
/// bindings persisted before the move to nested ``ShortcutStroke``s survive.
/// See https://github.com/manaflow-ai/cmux/issues/5422.
private struct LegacyFlatShortcut: Decodable {
let firstStroke: ShortcutStroke
let secondStroke: ShortcutStroke?

private enum CodingKeys: String, CodingKey {
case key, command, shift, option, control, keyCode
case chordKey, chordCommand, chordShift, chordOption, chordControl, chordKeyCode
}

init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// `key` is the legacy discriminator: every legacy value has it (an
// unbound binding is `key == ""`). Its absence means the payload is
// neither the new nor the legacy shape, so decoding throws and the
// caller falls back to the action's default binding.
let key = try c.decode(String.self, forKey: .key)
firstStroke = ShortcutStroke(
key: key,
command: try c.decodeIfPresent(Bool.self, forKey: .command) ?? false,
shift: try c.decodeIfPresent(Bool.self, forKey: .shift) ?? false,
option: try c.decodeIfPresent(Bool.self, forKey: .option) ?? false,
control: try c.decodeIfPresent(Bool.self, forKey: .control) ?? false,
keyCode: try c.decodeIfPresent(UInt16.self, forKey: .keyCode)
)
if let chordKey = try c.decodeIfPresent(String.self, forKey: .chordKey), !chordKey.isEmpty {
secondStroke = ShortcutStroke(
key: chordKey,
command: try c.decodeIfPresent(Bool.self, forKey: .chordCommand) ?? false,
shift: try c.decodeIfPresent(Bool.self, forKey: .chordShift) ?? false,
option: try c.decodeIfPresent(Bool.self, forKey: .chordOption) ?? false,
control: try c.decodeIfPresent(Bool.self, forKey: .chordControl) ?? false,
keyCode: try c.decodeIfPresent(UInt16.self, forKey: .chordKeyCode)
)
} else {
secondStroke = nil
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract LegacyFlatShortcut into its own file.

This introduces a second meaningful type in a Packages/**/*.swift file; please move it to LegacyFlatShortcut.swift (or an equivalently type-named file) to keep package boundaries and navigation consistent.

As per coding guidelines: Packages/**/*.swift: “One major type per file; each struct, class, enum, actor, or protocol … with meaningful body lives in its own file named after the type.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift`
around lines 84 - 126, Move the private struct LegacyFlatShortcut (and its
init(from:) decoder logic that references ShortcutStroke) into a new file named
LegacyFlatShortcut.swift and remove the file-private visibility so the type is
internal (i.e., drop the leading "private") so StoredShortcut's init(from:) can
still decode using LegacyFlatShortcut; keep the same CodingKeys, Decodable
conformance, and behavior unchanged and run a build to ensure ShortcutStroke is
visible to the new file.

@lawrencecchen
lawrencecchen dismissed coderabbitai[bot]’s stale review June 6, 2026 11:34

CodeRabbit now posts non-blocking comment reviews (request_changes_workflow=false, #5538).

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — ac32c3a4 Deployed Jun 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyboard shortcut customizations wiped after updating to v0.64.11+ (StoredShortcut JSON shape changed without migration)

3 participants