🐛 Skip unknown entries and reserve keys in shortcut settings (#12473)

* 🐛 Skip unknown entries when importing shortcuts

The shortcuts import rejected the whole file when it held a single entry
it did not know, so a profile exported from an older Penpot version, or
edited by hand, could not be imported at all. The spec in #9924 asks to
skip unknown keys, tell the user which ones, and apply the rest.

Importing now keeps the entries that map to a known shortcut with a valid
key combination and skips the rest: unknown contexts, unknown shortcut
keys, and commands that are not a key combination. The import modal
lists the skipped entries. The file is only rejected when it is not a
map or holds nothing that can be applied.

A context whose entries are all skipped is left out: an empty context
clears the user's shortcuts on import, so keeping it would wipe them.

valid-command? reads a command the way mousetrap does, so it accepts
every default shortcut and rejects strings mousetrap could never match.

AI-assisted-by: claude-opus-5-5

* 📎 Sort requires in shortcuts settings test

cljfmt expects the namespace requires in alphabetical order.

AI-assisted-by: claude-opus-5-5

* ✨ Reserve cancel, copy and paste shortcuts

Cancel, copy and paste are no longer customizable, in the workspace and
in the path editor, which shares their shortcut keys. Their key
combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the
shortcut recorder warns and blocks saving one of them for another
action, and the import skips entries that use them.

Reserved combinations come from the non-customizable workspace
shortcuts, so marking another one as non-customizable reserves its key
too. The path editor's cancel also binds V, the move tool key, so path
shortcuts are left out. Combinations compare the way mousetrap matches
them: aliases and modifier order do not matter.

apply-custom-overrides now ignores overrides of non-customizable
shortcuts. An override saved before this change would otherwise still
apply, with no way to reset it from the settings page.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-22 12:24:12 +02:00 committed by GitHub
parent 452d751dff
commit ce5276065e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 447 additions and 22 deletions

View File

@ -187,8 +187,11 @@
shortcuts
(reduce-kv
(fn [acc sc-key new-command]
;; A non-customizable shortcut ignores overrides stored before it
;; became so: the settings page no longer offers to reset them.
(if (and (contains? acc sc-key)
(not (:disabled (get acc sc-key))))
(not (:disabled (get acc sc-key)))
(not (false? (:customizable (get acc sc-key)))))
(-> acc
(assoc-in [sc-key :original-command] (get-in acc [sc-key :command]))
(assoc-in [sc-key :command] new-command)

View File

@ -127,6 +127,7 @@
:label (fn [] (tr "shortcuts.copy"))
:command (ds/c-mod "c")
:subsections [:path-editor]
:customizable false
:fn #(st/emit! (drp/copy-selected-nodes))}
:cut {:tooltip (ds/meta "X")
@ -139,6 +140,7 @@
:label (fn [] (tr "shortcuts.paste"))
:command (ds/c-mod "v")
:subsections [:path-editor]
:customizable false
:fn #(st/emit! (drp/paste-nodes))}
:duplicate {:tooltip (ds/meta "D")
@ -176,6 +178,7 @@
:label (fn [] (tr "shortcuts.escape"))
:command ["escape" "v"]
:section [:workspace]
:customizable false
:fn #(st/emit! (esc-pressed))}
;; Reuses the `:start-editing` key (instead of adding "enter" to

View File

@ -94,6 +94,7 @@
:command (ds/c-mod "c")
:subsections [:edit]
:section [:workspace]
:customizable false
:fn #(st/emit! (dw/copy-selected))}
:copy-link {:tooltip (ds/shift (ds/alt "C"))
@ -178,6 +179,7 @@
:command "escape"
:subsections [:edit]
:section [:workspace]
:customizable false
:fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))}
:find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit]

View File

@ -65,7 +65,7 @@
(mf/defc import-shortcuts-diff-modal
{::mf/register modal/components
::mf/register-as :import-shortcuts-diff-modal}
[{:keys [imported-shortcuts custom-shortcuts all-shortcuts-raw]}]
[{:keys [imported-shortcuts skipped-entries custom-shortcuts all-shortcuts-raw]}]
(let [diff-entries
(mf/with-memo [imported-shortcuts custom-shortcuts]
(->> (compute-diff imported-shortcuts custom-shortcuts)
@ -145,7 +145,17 @@
:is-customized true
:has-conflict? false}])]]])))]]
[:div {:class (stl/css :no-changes)}
(tr "import-shortcuts.no-changes")])]
(tr "import-shortcuts.no-changes")])
(when (seq skipped-entries)
[:div {:class (stl/css :skipped-entries)}
[:div {:class (stl/css :modal-content-text)}
(tr "import-shortcuts.skipped-entries")]
[:ul {:class (stl/css :skipped-entries-list)}
(for [entry skipped-entries]
[:li {:key entry
:class (stl/css :skipped-entry)}
entry])]])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}

View File

@ -165,3 +165,22 @@
text-align: center;
padding-block: var(--sp-xl);
}
.skipped-entries {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.skipped-entries-list {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.skipped-entry {
@include t.use-typography("code-font");
color: var(--color-foreground-secondary);
overflow-wrap: anywhere;
}

View File

@ -121,6 +121,53 @@
[:dashboard {:optional true} (context-schema :dashboard)]
[:viewer {:optional true} (context-schema :viewer)]]))
(defn- entry-path
[& segments]
(str/join "/" (map d/name segments)))
(defn- sanitize-context
"Keeps the entries of one imported context that map to a known shortcut key
with a valid command; every other entry goes to :skipped."
[result ctx entries known-keys]
(let [viable (reduce-kv
(fn [viable k command]
(if (and (contains? known-keys k)
(ss/valid-command? command)
(not (ss/reserved-command? command)))
(assoc viable k command)
viable))
{}
entries)
skipped (->> (keys entries)
(remove #(contains? viable %))
(map #(entry-path ctx %)))]
(cond-> (update result :skipped into skipped)
;; A context left empty by skipped entries is dropped: an empty context
;; clears the user's shortcuts on import, an explicit {} still does.
(or (seq viable) (empty? entries))
(assoc-in [:shortcuts ctx] viable))))
(defn sanitize-imported-shortcuts
"Splits an imported custom-shortcuts payload into the entries Penpot can
apply and the ones it must skip: unknown contexts, unknown or
non-customizable shortcut keys, commands that are not a key combination
(see `app.main.ui.shortcuts/valid-command?`) and reserved combinations
(see `app.main.ui.shortcuts/reserved-command?`).
Returns {:shortcuts <viable payload> :skipped [\"context/key\" ...]}, or
nil when the payload is not a map."
[data]
(when (map? data)
(-> (reduce-kv
(fn [result ctx entries]
(let [known-keys (get known-shortcut-keys ctx)]
(if (and (some? known-keys) (map? entries))
(sanitize-context result ctx entries known-keys)
(update result :skipped conj (entry-path ctx)))))
{:shortcuts {} :skipped []}
data)
(update :skipped (comp vec sort)))))
(defn- flatten-humanize
"Turns Malli's humanized explain output into a flat list of {:path :message} maps."
([human] (flatten-humanize [] human))
@ -450,12 +497,16 @@
(rx/subs!
(fn [content]
(try
(let [shortcuts (js->clj (.parse js/JSON content)
:keywordize-keys true)
validation (validate-imported-shortcuts shortcuts)]
(if (:valid? validation)
(let [data (js->clj (.parse js/JSON content)
:keywordize-keys true)
{:keys [shortcuts skipped]} (sanitize-imported-shortcuts data)]
;; Rejects the file only when nothing in it can be applied.
(if (and (some? shortcuts)
(or (seq shortcuts) (empty? skipped))
(:valid? (validate-imported-shortcuts shortcuts)))
(st/emit! (modal/show {:type :import-shortcuts-diff-modal
:imported-shortcuts shortcuts
:skipped-entries skipped
:custom-shortcuts custom-shortcuts
:all-shortcuts-raw all-shortcuts-raw}))
(st/emit! (ntf/error (tr "import-shortcuts.invalid-file")))))

View File

@ -68,6 +68,122 @@
"Meta" "meta"
" " "space"})
;; Mirrors how mousetrap reads a bind string (`_keysFromString` and
;; `_getKeyInfo` in @penpot/mousetrap), so a command is valid exactly when
;; mousetrap can match it.
(def ^:private command-aliases
{"ctrl" "ctrl"
"control" "ctrl"
"alt" "alt"
"option" "alt"
"shift" "shift"
"meta" "meta"
"command" "meta"
"return" "enter"
"enter" "enter"
"esc" "escape"
"escape" "escape"
"del" "del"
"delete" "del"
"ins" "ins"
"insert" "ins"
"space" "space"
"plus" "+"})
(def ^:private command-modifiers
#{"ctrl" "alt" "shift" "meta"})
(def ^:private command-named-keys
(into #{} (remove command-modifiers) (vals key-name-map)))
(defn- canonical-command-key
[token]
(cond
;; `mod` follows the platform, like `ds/c-mod`.
(= "mod" token)
(if (cf/check-platform? :macos) "meta" "ctrl")
(contains? command-aliases token)
(get command-aliases token)
(= 1 (count token))
(str/lower token)
:else
(str/lower (get key-name-map token token))))
(defn- command-step-keys
"Splits one step of a command (\"ctrl+shift+z\") into canonical keys.
A lone \"+\" is the plus key, and \"++\" ends a step with it."
[step]
(if (= "+" step)
["+"]
(->> (.split (str/replace step #"\+{2}" "+plus") "+")
(mapv canonical-command-key))))
(defn- final-command-key?
[k]
(or (= 1 (count k))
(contains? command-named-keys k)
;; Two-digit alternatives first: cljs `re-matches` does not
;; backtrack into a longer alternative.
(some? (re-matches #"f(?:1[0-9]|2[0-4]|[1-9])" k))
(some? (re-matches #"num[0-9]" k))))
(defn- valid-command-step?
"A lone modifier is a valid step: `:show-measure` binds a bare `alt`."
[step-keys]
(let [final-key (peek step-keys)]
(if (contains? command-modifiers final-key)
(= 1 (count step-keys))
(and (every? command-modifiers (pop step-keys))
(final-command-key? final-key)))))
(defn valid-command?
"True when `command` is a key combination mousetrap can match: steps
separated by single spaces, each a lone modifier or modifiers joined
with \"+\" ending with a single character, a named key (`escape`, `up`,
...), a function key (`f1`..`f24`) or a numpad digit (`num0`..`num9`).
The empty string is valid: it disables the shortcut."
[command]
(and (string? command)
(or (= "" command)
(every? (comp valid-command-step? command-step-keys)
(.split command " ")))))
(defn- canonical-command-step
"One step in the form mousetrap matches on: canonical keys with the
modifiers sorted, so `Shift+Ctrl+Z` and `ctrl+shift+z` compare equal."
[step]
(let [step-keys (command-step-keys step)
final-key (peek step-keys)]
(str/join "+" (conj (vec (sort (pop step-keys))) final-key))))
(defn- reserved-command-steps
"Key combinations of the non-customizable workspace shortcuts (cancel,
copy, paste): assigning them to another action would bind one key to two
actions. The path editor's cancel also binds `v`, the move tool key,
so path shortcuts are left out."
[]
(into #{}
(comp (filter #(false? (:customizable (val %))))
(mapcat #(let [command (:command (val %))]
(if (vector? command) command [command])))
(mapcat #(.split % " "))
(map canonical-command-step))
wsc/shortcuts))
(defn reserved-command?
"True when any step of `command` is a combination Penpot keeps for a
non-customizable shortcut."
[command]
(and (string? command)
(not= "" command)
(let [reserved (reserved-command-steps)]
(some? (some #(contains? reserved (canonical-command-step %))
(.split command " "))))))
(defn- event->native
"Unwraps a goog.events.BrowserEvent to the native event, since the
wrapper copies `key` but not `code`."
@ -601,12 +717,16 @@
effective-section-key))
(clean-editing-state)))
reserved? (and (some? recorded-command)
(not reset-pending)
(reserved-command? recorded-command))
save-fn
(mf/use-fn
(mf/deps recorded-command conflict command effective-section-key)
(mf/deps recorded-command reserved? conflict command effective-section-key)
(fn [event]
(dom/prevent-default event)
(when recorded-command
(when (and recorded-command (not reserved?))
(if (deref reset-pending*)
(st/emit! (customize/reset-custom-shortcut command recorded-command effective-section-key))
(st/emit! (customize/set-custom-shortcut
@ -710,9 +830,16 @@
:has-conflict? false}]])
(when (and recorded-command (not reset-pending))
(if conflict
(cond
reserved?
[:> context-notification* {:level :warning :class (stl/css :modal-error-msg)}
(tr "shortcuts.edit-modal.reserved")]
conflict
[:> context-notification* {:level :warning :class (stl/css :modal-error-msg)}
(tr "shortcuts.edit-modal.conflict" (:name conflict))]
:else
[:> context-notification* {:level :success :class (stl/css :modal-success-msg)}
(tr "shortcuts.edit-modal.success")]))
[:div {:class (stl/css :edit-buttons)}
@ -725,6 +852,7 @@
[:> icon-button* {:variant "primary"
:aria-label (tr "labels.save")
:on-click save-fn
:disabled reserved?
:icon i/tick
:icon-size "m"}]]
[:div {:class (stl/css :confirmation-buttons)}

View File

@ -1,8 +1,12 @@
(ns frontend-tests.ui.settings-shortcuts-test
(:require
[app.config :as cf]
[app.main.data.dashboard.shortcuts :as dsc]
[app.main.data.profile :as du]
[app.main.data.shortcuts :as ds]
[app.main.data.viewer.shortcuts :as vsc]
[app.main.data.workspace.path.shortcuts :as psc]
[app.main.data.workspace.shortcuts :as wsc]
[app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal]
[app.main.ui.settings.restore-shortcuts-modal :as restore-modal]
[app.main.ui.settings.shortcuts :as sut]
@ -14,21 +18,21 @@
(t/deftest validate-imported-shortcuts-accepts-valid-workspace
(t/is (= {:valid? true}
(sut/validate-imported-shortcuts
{:workspace {:escape "escape"
{:workspace {:draw-rect "shift+r"
:increase-zoom "+"
:zoom-lense-decrease "alt+z"}}))))
(t/deftest validate-imported-shortcuts-accepts-optional-contexts
(t/is (= {:valid? true}
(sut/validate-imported-shortcuts
{:workspace {:escape "escape"}
{:workspace {:draw-rect "shift+r"}
:dashboard {:toggle-theme "alt+m"}
:viewer {:next-frame "right"}}))))
(t/deftest validate-imported-shortcuts-accepts-empty-string-command
(t/is (= {:valid? true}
(sut/validate-imported-shortcuts
{:workspace {:escape ""}}))))
{:workspace {:draw-rect ""}}))))
(t/deftest validate-imported-shortcuts-rejects-nil
(let [result (sut/validate-imported-shortcuts nil)]
@ -52,19 +56,19 @@
(t/is (some #(= "workspace" (:path %)) (:errors result)))))
(t/deftest validate-imported-shortcuts-rejects-non-string-command
(let [result (sut/validate-imported-shortcuts {:workspace {:escape 123}})]
(let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect 123}})]
(t/is (= false (:valid? result)))
(t/is (seq (:errors result)))
(t/is (some #(= "workspace/escape" (:path %)) (:errors result)))))
(t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result)))))
(t/deftest validate-imported-shortcuts-rejects-vector-command
(let [result (sut/validate-imported-shortcuts {:workspace {:escape ["ctrl+e"]}})]
(let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect ["ctrl+e"]}})]
(t/is (= false (:valid? result)))
(t/is (seq (:errors result)))
(t/is (some #(= "workspace/escape" (:path %)) (:errors result)))))
(t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result)))))
(t/deftest validate-imported-shortcuts-rejects-unknown-top-level-key
(let [result (sut/validate-imported-shortcuts {:workspace {:escape "escape"}
(let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect "shift+r"}
:unknown-context {}})]
(t/is (= false (:valid? result)))
(t/is (seq (:errors result)))
@ -87,6 +91,195 @@
(t/is (= false (:valid? (sut/validate-imported-shortcuts #{:workspace}))))
(t/is (= false (:valid? (sut/validate-imported-shortcuts (fn []))))))
(t/deftest sanitize-imported-shortcuts-skips-unknown-action-keys
(t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}}
:skipped ["workspace/not-a-real-shortcut"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-rect "shift+r"
:not-a-real-shortcut "x"}}))))
(t/deftest sanitize-imported-shortcuts-skips-unknown-contexts
(t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}}
:skipped ["unknown-context"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-rect "shift+r"}
:unknown-context {:draw-rect "shift+r"}}))))
(t/deftest sanitize-imported-shortcuts-skips-non-string-commands
(t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}}
:skipped ["workspace/draw-rect" "workspace/increase-zoom"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-rect 123
:increase-zoom ["+"]
:zoom-lense-decrease "alt+z"}}))))
(t/deftest sanitize-imported-shortcuts-skips-invalid-commands
(t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}}
:skipped ["workspace/draw-rect" "workspace/increase-zoom"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-rect "foo+bar"
:increase-zoom "ctrl+"
:zoom-lense-decrease "alt+z"}}))))
(t/deftest cancel-copy-and-paste-are-not-customizable
(t/testing "in the workspace and in the path editor, which share the
shortcut keys of the workspace context"
(doseq [shortcuts [wsc/shortcuts psc/shortcuts]
k [:escape :copy :paste]]
(t/is (false? (get-in shortcuts [k :customizable]))
(str k)))))
(t/deftest sanitize-imported-shortcuts-skips-non-customizable-keys
(t/testing "cancel, copy and paste cannot be imported, even with a free
key combination"
(t/is (= {:shortcuts {}
:skipped ["workspace/copy" "workspace/escape" "workspace/paste"]}
(sut/sanitize-imported-shortcuts
{:workspace {:escape "shift+escape"
:copy "ctrl+shift+1"
:paste "ctrl+shift+2"}})))))
(t/deftest apply-custom-overrides-skips-non-customizable-shortcuts
(t/testing "an override stored before a shortcut became non-customizable
is ignored, since the settings page can no longer reset it"
(let [shortcuts {:escape {:command "escape" :customizable false}
:undo {:command "ctrl+z"}}
result (ds/apply-custom-overrides shortcuts
{:workspace {:escape "shift+escape"
:undo "ctrl+u"}}
:workspace)]
(t/is (= "escape" (get-in result [:escape :command])))
(t/is (= "ctrl+u" (get-in result [:undo :command]))))))
(t/deftest sanitize-imported-shortcuts-skips-reserved-commands
(t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}}
:skipped ["workspace/draw-rect" "workspace/increase-zoom"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-rect "escape"
:increase-zoom "ctrl+v"
:zoom-lense-decrease "alt+z"}}))))
(t/deftest sanitize-imported-shortcuts-skips-copy-combination
(t/is (= {:shortcuts {:workspace {:draw-rect "ctrl+alt+c"}}
:skipped ["workspace/draw-ellipse"]}
(sut/sanitize-imported-shortcuts
{:workspace {:draw-ellipse "ctrl+c"
:draw-rect "ctrl+alt+c"}}))))
(t/deftest sanitize-imported-shortcuts-skips-non-map-context
(t/is (= {:shortcuts {:viewer {:next-frame "right"}}
:skipped ["workspace"]}
(sut/sanitize-imported-shortcuts
{:workspace "not-a-map"
:viewer {:next-frame "right"}}))))
(t/deftest sanitize-imported-shortcuts-drops-context-without-viable-entries
(t/testing "a context whose entries are all skipped is left out, so the
import does not clear the user's shortcuts in that context"
(t/is (= {:shortcuts {}
:skipped ["viewer/not-a-real-shortcut"]}
(sut/sanitize-imported-shortcuts
{:viewer {:not-a-real-shortcut "x"}})))))
(t/deftest sanitize-imported-shortcuts-keeps-empty-context
(t/is (= {:shortcuts {:workspace {}}
:skipped []}
(sut/sanitize-imported-shortcuts {:workspace {}}))))
(t/deftest sanitize-imported-shortcuts-returns-nil-for-non-maps
(t/is (nil? (sut/sanitize-imported-shortcuts nil)))
(t/is (nil? (sut/sanitize-imported-shortcuts "shortcuts")))
(t/is (nil? (sut/sanitize-imported-shortcuts 42)))
(t/is (nil? (sut/sanitize-imported-shortcuts []))))
(t/deftest sanitize-imported-shortcuts-output-is-valid
(let [{:keys [shortcuts]} (sut/sanitize-imported-shortcuts
{:workspace {:draw-rect "shift+r"
:not-a-real-shortcut "x"
:increase-zoom 1}
:dashboard "not-a-map"
:viewer {:next-frame "right"}
:unknown-context {}})]
(t/is (= {:valid? true}
(sut/validate-imported-shortcuts shortcuts)))))
(t/deftest valid-command-accepts-key-combinations
(t/are [command] (true? (ui-shortcuts/valid-command? command))
""
"a"
"ctrl+shift+k"
"Ctrl+s"
"command+alt+ñ"
"+"
"shift++"
"ctrl+plus"
"shift+\""
"g v"
"escape"
"Escape"
"f1"
"f12"
"f24"
"num5"
"alt"))
(t/deftest valid-command-rejects-other-strings
(t/are [command] (false? (ui-shortcuts/valid-command? command))
"foo"
"foo+bar"
"a+b"
"ctrl+foo"
"ctrl+"
"ctrl+shift"
"++"
"f25"
" "
" a"
"a "
"a b"
"🙂"
nil
123))
(t/deftest reserved-command-matches-non-customizable-combinations
(t/testing "cancel, copy and paste keep their keys; spelling and modifier
order do not matter, and a reserved step taints a sequence"
(with-redefs [cf/check-platform? (constantly false)]
(t/are [command] (true? (ui-shortcuts/reserved-command? command))
"escape"
"Escape"
"esc"
"ctrl+c"
"Control+C"
"mod+c"
"ctrl+v"
"Control+V"
"mod+v"
"g escape"
"ctrl+c g"))))
(t/deftest reserved-command-ignores-other-combinations
(with-redefs [cf/check-platform? (constantly false)]
(t/are [command] (false? (ui-shortcuts/reserved-command? command))
""
"v"
"c"
"shift+escape"
"ctrl+shift+v"
"ctrl+shift+c"
"ctrl+alt+c"
"shift+alt+c"
nil)))
(t/deftest valid-command-accepts-every-default-command
(t/testing "a profile exported by Penpot always holds valid commands"
(doseq [shortcuts [psc/shortcuts wsc/shortcuts dsc/shortcuts vsc/shortcuts]
[shortcut-key {:keys [command]}] shortcuts
command (if (vector? command) command [command])
:when (some? command)]
(t/is (ui-shortcuts/valid-command? command)
(str shortcut-key " -> " (pr-str command))))))
(t/deftest import-custom-shortcuts-updates-multiple-contexts
(let [imported {:workspace {:add-comment "alt+c"}
:viewer {:select-all "ctrl+alt+a"}}
@ -173,17 +366,17 @@
(t/is (= "ctrl+shift+a" (:imported select-all-entry))))))
(t/deftest compute-diff-shows-empty-string-as-disabled-shortcut
(let [imported {:workspace {:escape ""}}
(let [imported {:workspace {:draw-rect ""}}
current {}
diff (diff-modal/compute-diff imported current)]
(t/is (pos? (count diff)))
(let [entry (first (filter #(= :escape (:key %)) diff))]
(let [entry (first (filter #(= :draw-rect (:key %)) diff))]
(t/is (= :workspace (:context entry)))
(t/is (= "" (:imported entry))))))
(t/deftest compute-diff-excludes-unchanged-shortcuts
(let [imported {:workspace {:escape ""}}
current {:workspace {:escape ""}}
(let [imported {:workspace {:draw-rect ""}}
current {:workspace {:draw-rect ""}}
diff (diff-modal/compute-diff imported current)]
(t/is (empty? diff))))

View File

@ -2181,6 +2181,10 @@ msgstr "This file is not a valid shortcuts export."
msgid "import-shortcuts.no-changes"
msgstr "No differences found between the current and imported shortcuts."
#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153
msgid "import-shortcuts.skipped-entries"
msgstr "These entries can't be imported: they don't match any Penpot shortcut or use a reserved key combination."
#: src/app/main/ui/components/color_input.cljs:31
msgid "inspect.attributes.color"
msgstr "Color"
@ -4937,6 +4941,10 @@ msgstr "Edit %s"
msgid "shortcuts.edit-modal.conflict"
msgstr "Already assigned to \"%s\". Saving will move it to this action."
#: src/app/main/ui/shortcuts.cljs:836
msgid "shortcuts.edit-modal.reserved"
msgstr "This combination is reserved by Penpot and can't be assigned."
#: src/app/main/ui/shortcuts.cljs:636
msgid "shortcuts.edit-modal.reset-pending"
msgstr "Saving will reset this shortcut to default:"

View File

@ -2207,6 +2207,10 @@ msgstr "Este archivo no es una exportación de atajos válida."
msgid "import-shortcuts.no-changes"
msgstr "No se encontraron diferencias entre los atajos actuales y los importados."
#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153
msgid "import-shortcuts.skipped-entries"
msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada."
#: src/app/main/ui/components/color_input.cljs:31
msgid "inspect.attributes.color"
msgstr "Color"
@ -4991,6 +4995,10 @@ msgstr "Editar %s"
msgid "shortcuts.edit-modal.conflict"
msgstr "Ya asignado a \"%s\". Al guardar, pasará a esta acción."
#: src/app/main/ui/shortcuts.cljs:836
msgid "shortcuts.edit-modal.reserved"
msgstr "Penpot reserva esta combinación y no se puede asignar."
#: src/app/main/ui/shortcuts.cljs:636
msgid "shortcuts.edit-modal.reset-pending"
msgstr "Al guardar, este atajo volverá a su valor por defecto:"