Add author, relative timestamp and short identifier to history entries (#9132)

The workspace Actions history panel previously showed the operation
icon and a one-line message for each undo entry with no indication
of when the action happened, any stable way to refer to it, or who
made the change. The reporter of issue #7660 (and @Takhoffman's
follow-up comment) asked for a git-like display: `<hash> · <time> by
<name>`.

This change stamps each undo entry with its creation timestamp and
author at the moment it lands on the undo stack and surfaces three
extra pieces of information in the history sidebar:

- A short 7-character identifier derived from the entry's existing
  `:undo-group` UUID. Hovering shows the full UUID.
- A relative timestamp (e.g. `just now`, `5 minutes ago`, `2 hours
  ago`, `3 days ago`) rendered via `app.common.time/timeago` so it
  matches the formatting already used for comments and the dashboard.
- The display name of the profile that created the entry, rendered
  as `by <Name>` in the same metadata row.

The undo stack is client-side per profile, so every entry is always
the current user; the author is stored on the entry anyway so the UI
does not need to reach into profile state while rendering and so the
data stays correct if the stack shape ever changes.

Changes at a glance:

- `data/workspace/undo.cljs`: extend `schema:undo-entry` with an
  optional `:timestamp` and `:by`; new `profile-display-name` helper
  that falls back from full name to email to nil; `stamp-entry` now
  takes state and fills in both fields on entries that do not
  already carry them. Pre-stamped entries (e.g. coming out of an
  accumulated transaction) keep their original values.
- `ui/workspace/sidebar/history.cljs`: propagate `:timestamp`,
  `:undo-group`, and `:by` through `parse-entries`; add `short-id`
  helper; render the metadata row in `history-entry` using
  `app.common.time/timeago` against `:timestamp`; skip the author
  span entirely when `:by` is nil.
- `ui/workspace/sidebar/history.scss`: styling for the new metadata
  row (monospace hash, muted separator, truncated time/author).
- `translations/en.po`: 1 new string for `by %s`.

Existing undo entries created before this change have neither
timestamp nor author; the UI is defensive about both, so old entries
simply render with whatever data they have (and often the plain
title on its own).

Github #7660

Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Signed-off-by: FairyPiggyDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
FairyPiggyDev 2026-05-11 08:48:32 -04:00 committed by GitHub
parent f7fbd3007e
commit 09bd7f96f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 131 additions and 8 deletions

View File

@ -33,6 +33,8 @@
- Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714)
- Add "Delete group" option to the assets panel context menu for components, colors and typographies (by @FairyPigDev) [Github #9141](https://github.com/penpot/penpot/issues/9141)
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree rooted at that layer in the Layers sidebar; symmetric with the existing `Shift+click` collapse-all gesture, and removes the O(siblings × depth) click cost of unfolding a deep subtree one level at a time [Github #7736](https://github.com/penpot/penpot/issues/7736)
- Show relative timestamp and short identifier for each entry in the workspace Actions history sidebar (by @FairyPigDev) [Github #7660](https://github.com/penpot/penpot/issues/7660)
- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree in the Layers sidebar (by @MilosM348) [Github #9179](https://github.com/penpot/penpot/pull/9179)
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)

View File

@ -62,7 +62,16 @@
[:undo-group ::sm/uuid]
[:tags [:set :keyword]]
[:selected-before {:optional true} [:maybe [:set ::sm/uuid]]]
[:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]])
[:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]
;; When the entry was pushed onto the stack; used by the actions
;; history panel to show a relative timestamp next to each entry.
;; Issue #7660.
[:timestamp {:optional true} [:maybe some?]]
;; Display name of the profile that created the entry. The undo
;; stack is client-side per profile, so this is always the current
;; user; still stored explicitly so the UI does not need to reach
;; into profile state while rendering. Issue #7660.
[:by {:optional true} [:maybe :string]]])
(def check-undo-entry
(sm/check-fn schema:undo-entry))
@ -87,6 +96,26 @@
(update [_ state]
(update state :workspace-undo assoc :index index))))
(defn- profile-display-name
"Best-effort display name for the current profile. Prefers the full
name, falls back to the email, and finally to nil so the UI can
simply skip the 'by …' suffix when we have nothing useful to show.
Issue #7660."
[state]
(let [profile (get state :profile)]
(or (:fullname profile)
(:email profile))))
(defn- stamp-entry
"Attach creation metadata to an undo entry. We only stamp the timestamp
and author when they are missing so already-enriched entries (e.g.
coming from an accumulated transaction that was opened earlier) keep
their original creation time and attribution. Issue #7660."
[state entry]
(cond-> entry
(nil? (:timestamp entry)) (assoc :timestamp (ct/now))
(nil? (:by entry)) (assoc :by (profile-display-name state))))
(defn- add-undo-entry
[state entry]
(if (and entry
@ -95,7 +124,7 @@
(let [index (get-in state [:workspace-undo :index] -1)
items (get-in state [:workspace-undo :items] [])
items (->> items (take (inc index)) (into []))
items (conj-undo-entry items entry)]
items (conj-undo-entry items (stamp-entry state entry))]
(-> state
(update :workspace-undo assoc :items items
:index (min (inc index)
@ -104,7 +133,9 @@
(defn- stack-undo-entry
"Extends the current undo entry in the workspace with new changes if it
exists, or creates a new entry if it doesn't."
exists, or creates a new entry if it doesn't. When stacking onto an
existing entry, the entry's original timestamp is preserved so the
history panel keeps showing when the action originated. Issue #7660."
[state {:keys [undo-changes redo-changes selected-after] :as entry}]
(let [index (get-in state [:workspace-undo :index] -1)]
(if (>= index 0)

View File

@ -9,6 +9,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.time :as ct]
[app.main.data.workspace.undo :as dwu]
[app.main.refs :as refs]
[app.main.store :as st]
@ -172,6 +173,17 @@
(->> redo-changes
(map parse-change)))
(defn- short-id
"Build a short git-like label for an undo entry. Derives it from the
entry's `:undo-group` uuid (which the undo subsystem already generates
for every action). Issue #7660."
[entry]
(when-let [group (:undo-group entry)]
(let [s (str group)]
(-> s
(str/replace #"[^0-9a-f]" "")
(subs 0 (min 7 (count s)))))))
(defn safe-name [maybe-keyword]
(if (keyword? maybe-keyword)
(name maybe-keyword)
@ -253,10 +265,17 @@
(assoc selected-entry :detail detail)))
(defn parse-entries [entries objects]
(->> entries
(map parse-entry)
(map (resolve-shape-types entries objects))
(mapv select-entry)))
;; Propagate per-entry metadata (timestamp, undo-group, author) onto
;; the summarized result so the UI can show when the action happened,
;; a short stable identifier, and who made the change. Issue #7660.
(mapv (fn [raw-entry]
(-> (parse-entry raw-entry)
((resolve-shape-types entries objects))
(select-entry)
(assoc :timestamp (:timestamp raw-entry)
:undo-group (:undo-group raw-entry)
:by (:by raw-entry))))
entries))
(mf/defc history-entry-details* [{:keys [entry]}]
(let [{entries :items} (mf/deref workspace-undo)
@ -289,6 +308,11 @@
[{:keys [entry idx-entry disabled? current?]}]
(let [hover? (mf/use-state false)
show-detail? (mf/use-state false)
relative-time (ct/timeago (:timestamp entry))
label (short-id entry)
author (:by entry)
toggle-show-detail
(mf/use-fn
(fn [event]
@ -310,7 +334,28 @@
[:div {:class (stl/css :history-entry-summary)}
[:div {:class (stl/css :history-entry-summary-icon)}
(entry->icon entry)]
[:div {:class (stl/css :history-entry-summary-text)} (entry->message entry)]
[:div {:class (stl/css :history-entry-summary-text)}
[:div {:class (stl/css :history-entry-title)}
(entry->message entry)]
;; Metadata row: short identifier, relative timestamp, and
;; author. Rendered as plain inline text so the natural word
;; spacing between tokens ("17bc430 · just now by <Name>") is
;; preserved without relying on flex gap. Issue #7660.
(when (or label relative-time author)
[:div {:class (stl/css :history-entry-meta)}
(when label
[:span {:class (stl/css :history-entry-hash)
:title (dm/str (:undo-group entry))}
label])
(when (and label relative-time)
[:span {:class (stl/css :history-entry-meta-sep)} " · "])
(when relative-time
[:span {:class (stl/css :history-entry-time)}
relative-time])
(when (and (or label relative-time) author) " ")
(when author
[:span {:class (stl/css :history-entry-author)}
(tr "workspace.undo.entry.by" author)])])]
(when (:detail entry)
[:div {:class (stl/css-case :history-entry-summary-button true
:button-opened @show-detail?)

View File

@ -54,8 +54,49 @@
}
.history-entry-summary-text {
display: flex;
flex-direction: column;
gap: deprecated.$s-2;
margin: 0 deprecated.$s-8;
color: var(--color-foreground-primary);
min-inline-size: 0;
}
.history-entry-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Metadata row: short identifier, relative timestamp, and author.
// Plain inline layout so the literal spaces embedded in the
// template ("hash · time by Name") survive rendering. Issue #7660.
.history-entry-meta {
color: var(--color-foreground-secondary);
font-size: deprecated.$fs-10;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-entry-hash {
font-family: monospace;
letter-spacing: 0.03em;
}
.history-entry-meta-sep {
opacity: 0.6;
}
.history-entry-time {
white-space: nowrap;
}
// Author ("by <name>") the surrounding meta row already
// truncates, so keep the author span as a plain inline. Issue
// #7660.
.history-entry-author {
white-space: nowrap;
}
.history-entry-summary-button {

View File

@ -9014,6 +9014,10 @@ msgstr "Refresh"
msgid "workspace.undo.empty"
msgstr "There are no history changes so far"
#: src/app/main/ui/workspace/sidebar/history.cljs
msgid "workspace.undo.entry.by"
msgstr "by %s"
#: src/app/main/ui/workspace/sidebar/history.cljs:147
msgid "workspace.undo.entry.delete"
msgstr "Deleted %s"