Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop-gpui/assets/icons/history.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons":
"prev.svg",
"rectangle-horizontal.svg",
"redo.svg",
"history.svg",
"restart.svg",
"scissors.svg",
"square.svg",
Expand Down
223 changes: 223 additions & 0 deletions apps/desktop-gpui/src/editor_edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ const HISTORY_LIMIT: usize = 100;
#[derive(Debug, Clone)]
pub struct ProjectHistory {
entries: Vec<ProjectConfiguration>,
/// Whether the limit has dropped the oldest entries, so the first one left
/// is no longer the state the project opened in.
trimmed: bool,
revision: u64,
/// `history().count` -- steps walked back from the newest entry.
count: usize,
/// `pauseCount` (`:1917`). A drag brackets itself with `pause`/`resume` so
Expand All @@ -165,6 +169,8 @@ impl ProjectHistory {
pub fn new(initial: ProjectConfiguration) -> Self {
Self {
entries: vec![initial],
trimmed: false,
revision: 0,
count: 0,
paused: 0,
dirty: false,
Expand All @@ -183,9 +189,11 @@ impl ProjectHistory {
if self.entries.len() >= HISTORY_LIMIT {
let excess = self.entries.len() - HISTORY_LIMIT + 1;
self.entries.drain(0..excess);
self.trimmed = true;
}
self.entries.push(config.clone());
self.count = 0;
self.revision += 1;
}

/// `projectHistory.pause()`.
Expand Down Expand Up @@ -234,12 +242,142 @@ impl ProjectHistory {
self.entries.get(self.entries.len() - self.count - 1)
}

pub fn len(&self) -> usize {
self.entries.len()
}

/// Each entry's label, oldest first. Worked out on demand (when the
/// History menu opens) so `record` never pays for the diff.
pub fn labels(&self) -> Vec<String> {
let first = if self.trimmed {
"Earlier changes"
} else {
"Opened project"
};
std::iter::once(first.to_string())
.chain(
self.entries
.windows(2)
.map(|pair| describe_change(&pair[0], &pair[1])),
)
.collect()
}

pub fn revision(&self) -> u64 {
self.revision
}

pub fn cursor(&self) -> usize {
self.entries.len() - self.count - 1
}

pub fn jump_to(&mut self, index: usize) -> Option<&ProjectConfiguration> {
if index >= self.entries.len() || index == self.cursor() {
return None;
}
self.count = self.entries.len() - 1 - index;
self.entries.get(index)
}

#[cfg(test)]
fn depth(&self) -> usize {
self.entries.len()
}
}

/// A short human label for what changed between two project snapshots, e.g.
/// "Added zoom" or "Changed background". Clip edits come first: deleting or
/// retiming a clip ripples every other track, and the label should name the
/// clip edit, not a track it shifted. Among the rest, a track whose segment
/// count changed beats one whose segments only moved.
pub fn describe_change(before: &ProjectConfiguration, after: &ProjectConfiguration) -> String {
let (Ok(serde_json::Value::Object(before)), Ok(serde_json::Value::Object(after))) =
(serde_json::to_value(before), serde_json::to_value(after))
else {
return "Edit".to_string();
};
let empty = serde_json::Map::new();
let before_timeline = before
.get("timeline")
.and_then(serde_json::Value::as_object)
.unwrap_or(&empty);
let after_timeline = after
.get("timeline")
.and_then(serde_json::Value::as_object)
.unwrap_or(&empty);
let count =
|value: Option<&serde_json::Value>| value.and_then(|v| v.as_array()).map_or(0, Vec::len);
let mut keys: Vec<&String> = before_timeline
.keys()
.chain(after_timeline.keys())
.collect();
keys.sort();
keys.dedup();
let changed: Vec<&String> = keys
.into_iter()
.filter(|key| before_timeline.get(*key) != after_timeline.get(*key))
.collect();
let counted =
|key: &&String| count(before_timeline.get(*key)) != count(after_timeline.get(*key));
let pick = changed
.iter()
.find(|key| key.as_str() == "segments")
.or_else(|| changed.iter().find(|key| key.as_str() == "transitions"))
.or_else(|| changed.iter().find(|key| counted(key)))
.or_else(|| changed.first());
if let Some(key) = pick {
let name = match key.as_str() {
"segments" => "clip",
"zoomSegments" => "zoom",
"textSegments" => "text",
"imageSegments" => "image",
"audioSegments" => "audio clip",
"maskSegments" => "mask",
"sceneSegments" => "scene",
"styleSegments" => "style",
"keyboardSegments" => "keyboard segment",
"captionSegments" => "caption",
"camera3dSegments" => "3D segment",
"transitions" => "transition",
other => other,
};
let (before_count, after_count) = (
count(before_timeline.get(*key)),
count(after_timeline.get(*key)),
);
return match after_count.cmp(&before_count) {
std::cmp::Ordering::Greater if key.as_str() == "segments" => "Split clip".to_string(),
std::cmp::Ordering::Greater => format!("Added {name}"),
std::cmp::Ordering::Less => format!("Deleted {name}"),
std::cmp::Ordering::Equal => format!("Edited {name}"),
};
}
let mut keys: Vec<&String> = before.keys().chain(after.keys()).collect();
keys.sort();
keys.dedup();
for key in keys {
if key == "timeline" || before.get(key) == after.get(key) {
continue;
}
let name = match key.as_str() {
"aspectRatio" => "aspect ratio".to_string(),
"inOut" => "in/out range".to_string(),
other => other
.chars()
.flat_map(|c| {
if c.is_uppercase() {
vec![' ', c.to_ascii_lowercase()]
} else {
vec![c]
}
})
.collect(),
};
return format!("Changed {name}");
}
"Edit".to_string()
}

/// Compares the JSON the two configs save as, stopping at the first differing
/// byte. Building two `serde_json::Value` trees instead took ~14 ms on every
/// drag release in a captioned 20 minute project.
Expand Down Expand Up @@ -2974,6 +3112,91 @@ mod tests {
.map_or(0, |timeline| timeline.zoom_segments.len())
}

#[test]
fn history_labels_steps_and_jumps_between_them() {
let mut history = ProjectHistory::new(with_zoom_count(0));
history.record(&with_zoom_count(1));
history.record(&with_zoom_count(2));
history.record(&with_zoom_count(1));
assert_eq!(
history.labels(),
["Opened project", "Added zoom", "Added zoom", "Deleted zoom"]
);
assert_eq!(history.cursor(), 3);
assert_eq!(zoom_len(history.jump_to(0).unwrap()), 0);
assert_eq!(history.cursor(), 0);
assert!(history.can_redo());
assert_eq!(zoom_len(history.jump_to(2).unwrap()), 2);
assert!(history.jump_to(2).is_none(), "already there");
// A fresh edit drops the steps after the cursor
history.record(&with_zoom_count(5));
assert_eq!(history.labels().len(), 4);
assert_eq!(history.labels()[3], "Added zoom");
}

#[test]
fn a_clip_delete_is_labelled_as_one_even_when_it_shifts_other_tracks() {
let before = config(serde_json::json!({
"timeline": {
"segments": [
{ "recordingSegment": 0, "timescale": 1.0, "start": 0.0, "end": 10.0 },
{ "recordingSegment": 0, "timescale": 1.0, "start": 20.0, "end": 30.0 }
],
"zoomSegments": [],
"audioSegments": [
{ "start": 12.0, "end": 18.0, "track": 0, "path": "music.mp3", "name": "Music", "duration": 6.0 }
],
"captionSegments": [
{ "id": "a", "start": 1.0, "end": 2.0, "text": "kept" },
{ "id": "b", "start": 3.0, "end": 4.0, "text": "deleted" }
]
}
}));
let mut after = before.clone();
let timeline = after.timeline.as_mut().unwrap();
timeline.segments.remove(0);
timeline.audio_segments[0].start -= 10.0;
timeline.audio_segments[0].end -= 10.0;
timeline.caption_segments.remove(1);
assert_eq!(describe_change(&before, &after), "Deleted clip");

// Without a clip change, a track that lost a segment beats one that
// only shifted.
let mut shifted = before.clone();
let timeline = shifted.timeline.as_mut().unwrap();
timeline.audio_segments[0].start += 1.0;
timeline.caption_segments.remove(1);
assert_eq!(describe_change(&before, &shifted), "Deleted caption");
}

#[test]
fn history_revision_moves_only_when_the_entries_change() {
let mut history = ProjectHistory::new(with_zoom_count(0));
history.record(&with_zoom_count(1));
let recorded = history.revision();
history.undo();
assert_eq!(history.revision(), recorded, "undo keeps the labels");
history.redo();
history.jump_to(0);
assert_eq!(history.revision(), recorded, "jumps keep the labels");
history.pause();
history.record(&with_zoom_count(2));
assert_eq!(history.revision(), recorded, "paused");
history.resume(&with_zoom_count(2));
assert_ne!(history.revision(), recorded, "resumed");
}

#[test]
fn labels_are_worked_out_on_demand_and_survive_the_limit() {
let mut history = ProjectHistory::new(with_zoom_count(0));
for count in 1..=(HISTORY_LIMIT + 5) {
history.record(&with_zoom_count(count % 3));
}
let labels = history.labels();
assert_eq!(labels.len(), HISTORY_LIMIT);
assert_eq!(labels[0], "Earlier changes");
}

#[test]
fn history_walks_back_and_forward_through_snapshots() {
let mut history = ProjectHistory::new(with_zoom_count(0));
Expand Down
Loading