Skip to content

Commit 759658d

Browse files
committed
fix: preserve editor readiness while refining loading transitions
2 parents fce2800 + 183ffe7 commit 759658d

95 files changed

Lines changed: 16204 additions & 242 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ jobs:
9090
- name: Test workspace tooling
9191
run: node --test scripts/bun-scripts.test.mjs scripts/check-tauri-plugin-versions.test.mjs
9292

93+
- name: Check email library
94+
run: |
95+
bun run emails:check
96+
bun test scripts/emails scripts/loops/profile.test.ts scripts/loops/lifecycle.test.ts scripts/loops/watchdog.test.ts
97+
bun run tsc --noEmit --strict --jsx react-jsx --allowImportingTsExtensions --target ESNext --module ESNext --moduleResolution bundler --resolveJsonModule --types bun --skipLibCheck scripts/loops/*.ts scripts/emails/*.ts emails/*.ts emails/marketing/*.ts
98+
9399
- name: Test web React compatibility
94100
run: >-
95101
bun run --cwd apps/web test

.github/workflows/loops-safety.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Loops delivery safety
2+
3+
on:
4+
schedule:
5+
- cron: "2-57/5 * * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: loops-delivery-safety
13+
cancel-in-progress: false
14+
15+
jobs:
16+
check:
17+
if: vars.LOOPS_WATCHDOG_ENABLED == 'true'
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 5
20+
steps:
21+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
22+
with:
23+
persist-credentials: false
24+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
25+
with:
26+
bun-version: "1.4.0"
27+
- name: Check lifecycle delivery and report required manual pauses
28+
env:
29+
LOOPS_API_KEY: ${{ secrets.LOOPS_API_KEY }}
30+
LOOPS_HEALTH_SECRET: ${{ secrets.LOOPS_HEALTH_SECRET }}
31+
run: bun scripts/loops/watchdog.ts --apply

apps/desktop-gpui/src/editor_window.rs

Lines changed: 61 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ use gpui::{
6262
AppContext as _, Bounds, Context, Entity, FocusHandle, FontWeight, Hsla, InteractiveElement,
6363
IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
6464
Point, Render, RenderImage, SharedString, StatefulInteractiveElement as _, StyleRefinement,
65-
Styled, StyledImage as _, Subscription, WeakEntity, Window, div, point, prelude::FluentBuilder,
66-
px, svg,
65+
Styled, Subscription, WeakEntity, Window, div, point, prelude::FluentBuilder, px, svg,
6766
};
6867

6968
use crate::{
@@ -134,8 +133,6 @@ pub fn default_preview_resolution() -> XY<u32> {
134133
// Shell metrics (`routes/editor/Editor.tsx:77-82`)
135134
// ---------------------------------------------------------------------------
136135

137-
/// The timeline height used until the project's tracks are known, which is
138-
/// what the auto height derives from.
139136
const DEFAULT_TIMELINE_HEIGHT: f32 = 260.;
140137
/// The reference height the player/timeline split scales against on a short
141138
/// viewport. It is not the floor -- see [`timeline::MIN_HUG_HEIGHT`].
@@ -574,31 +571,6 @@ pub fn frame_image(frame: &RenderedFrame) -> Option<Arc<RenderImage>> {
574571
])))
575572
}
576573

577-
/// The poster: the recording's own first-frame JPEG, decoded to at most the
578-
/// canvas's retina size. `thumbnail` (a box filter) over `resize` because the
579-
/// picture is on screen for well under a second.
580-
fn decode_poster(path: &std::path::Path) -> Option<Arc<RenderImage>> {
581-
let bytes = std::fs::read(path).ok()?;
582-
let image = image::load_from_memory(&bytes).ok()?;
583-
let (width, height) = (image.width().max(1), image.height().max(1));
584-
let scale = (1920. / width as f32).min(1080. / height as f32).min(1.);
585-
let mut scaled = if scale < 1. {
586-
image::imageops::thumbnail(
587-
&image.into_rgba8(),
588-
((width as f32 * scale) as u32).max(1),
589-
((height as f32 * scale) as u32).max(1),
590-
)
591-
} else {
592-
image.into_rgba8()
593-
};
594-
for pixel in scaled.pixels_mut() {
595-
pixel.0.swap(0, 2);
596-
}
597-
Some(Arc::new(RenderImage::new(smallvec::smallvec![
598-
image::Frame::new(scaled)
599-
])))
600-
}
601-
602574
/// What the frame pump hands the window.
603575
#[derive(Clone)]
604576
pub(crate) enum EditorPreviewFrame {
@@ -768,7 +740,7 @@ impl Render for EditorSectionView {
768740
// clips sidebar; the config sidebar is hidden, not destroyed
769741
// (`Editor.tsx:728-747`).
770742
EditorSection::Sidebar => {
771-
if !editor.project_ready() {
743+
if !editor.visual_ready() {
772744
return editor.render_preparing_sidebar(!matches!(
773745
editor.state,
774746
LoadState::Failed(_)
@@ -1405,13 +1377,6 @@ pub struct EditorWindow {
14051377
preparing_frame_presented: bool,
14061378
preparing_seed: Option<Arc<crate::editor_preparing::presentation::PreparingTimelineSeed>>,
14071379
pub(crate) latest_frame: Option<EditorPreviewFrame>,
1408-
/// The bundle's `screenshots/display.jpg`, letterboxed into the canvas
1409-
/// until the first composed frame lands -- decoded in parallel with
1410-
/// `EditorInstance` construction, so the editor opens onto a picture
1411-
/// rather than "Loading project...". The Solid app hides the same wait
1412-
/// behind a skeleton; a poster is the native equivalent with the added
1413-
/// courtesy of showing the recording itself.
1414-
poster: Option<Arc<RenderImage>>,
14151380
preview: Entity<PreviewFrameView>,
14161381
header: Entity<EditorSectionView>,
14171382
toolbar: Entity<EditorSectionView>,
@@ -1616,6 +1581,7 @@ pub struct EditorWindow {
16161581
/// The user's drag on the resize grip. `None` means the card hugs its
16171582
/// track rows and re-hugs whenever a track is added or removed.
16181583
timeline_height_override: Option<f32>,
1584+
initial_timeline_rows: Option<usize>,
16191585
timeline_resize: Option<(f32, f32)>,
16201586
/// The header's Presets dropdown (`PresetsDropdown.tsx`), and whichever
16211587
/// of its three dialogs is up. The dialog closes the menu when it opens,
@@ -1734,27 +1700,6 @@ impl EditorWindow {
17341700
true
17351701
});
17361702

1737-
// Decode the poster off-thread immediately: it races EditorInstance
1738-
// construction and reliably wins, so the first paint has a picture.
1739-
let poster_path = project_path.join("screenshots").join("display.jpg");
1740-
cx.spawn_in(window, async move |this, cx| {
1741-
let Some(poster) = cx
1742-
.background_executor()
1743-
.spawn(async move { decode_poster(&poster_path) })
1744-
.await
1745-
else {
1746-
return;
1747-
};
1748-
this.update(cx, |this, cx| {
1749-
if this.latest_frame.is_none() {
1750-
this.poster = Some(poster);
1751-
cx.notify();
1752-
}
1753-
})
1754-
.ok();
1755-
})
1756-
.detach();
1757-
17581703
let name_input = cx.new(|cx| {
17591704
let mut input = ui::TextInputState::single_line(window, cx);
17601705
input.set_disabled(true, cx);
@@ -1951,11 +1896,11 @@ impl EditorWindow {
19511896
audio_picker: None,
19521897
camera3d_setup: None,
19531898
timeline_height_override: None,
1899+
initial_timeline_rows: None,
19541900
timeline_resize: None,
19551901
presets_menu: None,
19561902
preset_dialog: None,
19571903
caption_sync_signature: None,
1958-
poster: None,
19591904
export: None,
19601905
clips: crate::editor_clips::ClipsState::default(),
19611906
}
@@ -2412,6 +2357,14 @@ impl EditorWindow {
24122357
self.selection.as_ref()
24132358
}
24142359

2360+
fn visual_ready(&self) -> bool {
2361+
self.project_ready()
2362+
&& self.latest_frame.is_some()
2363+
&& self.preparing_consumer.is_none()
2364+
&& self.ordinary_handoff_frame.is_none()
2365+
&& self.preparing_candidate_frame.is_none()
2366+
}
2367+
24152368
pub(crate) fn project_ready(&self) -> bool {
24162369
self.instance.is_some() && matches!(&self.state, LoadState::Ready(_))
24172370
}
@@ -2665,6 +2618,7 @@ impl EditorWindow {
26652618
}
26662619

26672620
pub fn set_instance(&mut self, instance: Arc<EditorInstance>) {
2621+
self.initial_timeline_rows = Some(self.timeline.rows.len());
26682622
self.preparing_retry_pending = false;
26692623
self.preparing_candidate_frame = None;
26702624
self.ordinary_handoff_frame = self
@@ -3043,6 +2997,16 @@ impl EditorWindow {
30432997
window: &mut Window,
30442998
cx: &mut Context<Self>,
30452999
) {
3000+
if !self.visual_ready() {
3001+
let key = &event.keystroke;
3002+
let close = (key.key == "w" && (key.modifiers.platform || key.modifiers.control))
3003+
|| (key.key == "f4" && key.modifiers.alt);
3004+
if !close {
3005+
window.prevent_default();
3006+
cx.stop_propagation();
3007+
}
3008+
return;
3009+
}
30463010
if !is_playback_shortcut(
30473011
&event.keystroke,
30483012
ui::text_input_has_focus(window, cx),
@@ -3065,7 +3029,7 @@ impl EditorWindow {
30653029
/// (`useEditorShortcuts.ts:10`) and `e.repeat` is ignored there
30663030
/// (`:42`) as `is_held` is here.
30673031
fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
3068-
if !self.project_ready() {
3032+
if !self.visual_ready() {
30693033
return;
30703034
}
30713035
if self.frame_controls.is_open() && event.keystroke.key == "escape" {
@@ -4920,17 +4884,12 @@ impl EditorWindow {
49204884
EditorVerticalLayout::new(viewport_height, value).timeline_height
49214885
}
49224886

4923-
/// The height the timeline card asks for: the user's dragged override if
4924-
/// there is one, otherwise the height that hugs the visible track rows.
4925-
/// Before the project loads there are no rows to hug, so the old fixed
4926-
/// height stands in and the card does not pop when they arrive.
49274887
fn preferred_timeline_height(&self) -> f32 {
49284888
self.timeline_height_override.unwrap_or_else(|| {
4929-
if self.timeline.rows.is_empty() {
4930-
DEFAULT_TIMELINE_HEIGHT
4931-
} else {
4932-
timeline::hug_height(self.timeline.rows.len())
4933-
}
4889+
DEFAULT_TIMELINE_HEIGHT
4890+
+ self.initial_timeline_rows.map_or(0., |initial| {
4891+
timeline::hug_height(self.timeline.rows.len()) - timeline::hug_height(initial)
4892+
})
49344893
})
49354894
}
49364895

@@ -5845,8 +5804,6 @@ impl EditorWindow {
58455804
frame.layout.output_size[1] as f32,
58465805
);
58475806
self.latest_frame = Some(frame.frame.clone());
5848-
// The composed frame supersedes the poster; free its atlas memory.
5849-
self.poster = None;
58505807
let previous = self.preview.update(cx, |preview, cx| {
58515808
preview.set_frame(frame.frame, frame_size, self.stats.clone(), cx)
58525809
});
@@ -8515,6 +8472,7 @@ impl EditorWindow {
85158472
ui::EditorButton::plain(&theme, "open-recording-bundle")
85168473
.left_icon("icons/folder.svg")
85178474
.tooltip(&theme, "Open recording bundle")
8475+
.disabled(!self.visual_ready())
85188476
.on_click(cx.listener(|this, _, _window, cx| {
85198477
this.open_recording_bundle(cx);
85208478
})),
@@ -8523,6 +8481,7 @@ impl EditorWindow {
85238481
ui::EditorButton::plain(&theme, "delete-recording")
85248482
.left_icon("icons/trash.svg")
85258483
.tooltip(&theme, "Delete recording")
8484+
.disabled(!self.visual_ready())
85268485
.on_click(cx.listener(|this, _, window, cx| {
85278486
this.delete_recording(window, cx);
85288487
})),
@@ -8564,7 +8523,16 @@ impl EditorWindow {
85648523
.when(self.sharing.is_some(), |group| {
85658524
group.child(self.render_reupload_button(cx))
85668525
})
8567-
.child(self.render_export_button(cx)),
8526+
.child(self.render_export_button(cx))
8527+
.relative()
8528+
.children((!self.visual_ready()).then(|| {
8529+
div()
8530+
.absolute()
8531+
.inset_0()
8532+
.occlude()
8533+
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
8534+
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
8535+
})),
85688536
);
85698537

85708538
#[cfg(target_os = "windows")]
@@ -8904,9 +8872,9 @@ impl EditorWindow {
89048872
self.preview
89058873
.clone()
89068874
.cached(StyleRefinement::default().size_full()),
8907-
self.render_preview_placeholder(false),
8875+
loading::preview(&theme, false),
89088876
),
8909-
(_, false) => self.render_preview_placeholder(true),
8877+
(_, false) => loading::preview(&theme, true),
89108878
};
89118879

89128880
div()
@@ -8916,25 +8884,6 @@ impl EditorWindow {
89168884
.overflow_hidden()
89178885
.bg(Hsla::from(theme.editor.card))
89188886
.child(body)
8919-
.children(
8920-
(!self.project_ready() && self.latest_frame.is_some()).then(|| {
8921-
div()
8922-
.absolute()
8923-
.bottom(px(12.))
8924-
.left(px(12.))
8925-
.px(px(10.))
8926-
.py(px(6.))
8927-
.rounded(px(6.))
8928-
.bg(Hsla::from(theme.editor.card))
8929-
.text_size(px(12.))
8930-
.text_color(Hsla::from(theme.editor.text_2))
8931-
.child(if self.preparing_consumer.is_some() {
8932-
"Preparing recording…"
8933-
} else {
8934-
"Opening editor…"
8935-
})
8936-
}),
8937-
)
89388887
// `CanvasElementsOverlay` + `SnapGuidesOverlay`
89398888
// (`Player.tsx:636-643`), both mounted inside the letterbox
89408889
// wrapper and only while a frame exists.
@@ -8945,26 +8894,6 @@ impl EditorWindow {
89458894
})
89468895
}
89478896

8948-
fn render_preview_placeholder(&self, animated: bool) -> gpui::AnyElement {
8949-
if let Some(poster) = self.poster.clone() {
8950-
div()
8951-
.absolute()
8952-
.inset_0()
8953-
.flex()
8954-
.items_center()
8955-
.justify_center()
8956-
.bg(Hsla::from(self.theme.editor.card))
8957-
.child(
8958-
gpui::img(poster)
8959-
.size_full()
8960-
.object_fit(gpui::ObjectFit::Contain),
8961-
)
8962-
.into_any_element()
8963-
} else {
8964-
loading::preview(&self.theme, animated)
8965-
}
8966-
}
8967-
89688897
/// `EditorErrorScreen` -- what a bundle that will not open shows instead of
89698898
/// the canvas.
89708899
fn render_error_state(&self, message: &str) -> impl IntoElement {
@@ -10139,6 +10068,11 @@ fn is_playback_shortcut(
1013910068
impl Render for EditorWindow {
1014010069
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1014110070
self.sync_appearance(window, cx);
10071+
let title_disabled = !self.visual_ready();
10072+
if self.name_input.read(cx).is_disabled() != title_disabled {
10073+
self.name_input
10074+
.update(cx, |input, cx| input.set_disabled(title_disabled, cx));
10075+
}
1014210076
// Fields first: a field created this frame has no text yet, and gpui
1014310077
// only renders on invalidation, so syncing before creating would leave
1014410078
// a brand-new box empty until something else asked for a frame.
@@ -10573,6 +10507,20 @@ impl Render for EditorWindow {
1057310507
}))
1057410508
// The open `KSelect` menu, painted last of all so it is over the
1057510509
// sidebar and the drag layers alike.
10510+
.children(
10511+
(!self.visual_ready() && !matches!(self.state, LoadState::Failed(_))).then(|| {
10512+
div()
10513+
.absolute()
10514+
.top(px(HEADER_HEIGHT))
10515+
.bottom_0()
10516+
.left_0()
10517+
.right_0()
10518+
.occlude()
10519+
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
10520+
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
10521+
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
10522+
}),
10523+
)
1057610524
.children(self.with_style_controls(|this| this.render_sidebar_menu(cx)))
1057710525
.children(self.render_toolbar_menu(cx))
1057810526
.children(self.render_frame_controls(window, cx))

0 commit comments

Comments
 (0)