D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL, connect-probe instead of pool retry on dead servers); Storage/Backend=pg + Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified against a Docker postgres:16), always-on clean-error tests otherwise. D4: DaVinci-style project manager — list with derived stats, create/ rename/duplicate/delete (confirm)/import/export (native dialogs, ove/otio/fcpxml), shown at startup and from the file menu; facade oakengine_library_* exports (list/create/delete/rename/duplicate/ import/export + project_load_library that binds write-through); save/save-as menu becomes 'export project file', open splits into from-library/from-file; status bar shows library write state; storage activates on app start and flushes on exit; spawn_modal reentrancy fixed (window-callback path) with a doc note. Also: the P1 audio test's environment probe was lost in the ffi purge; restored on cpal (the output device is cpal now).
539 lines
15 KiB
Rust
539 lines
15 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
//! The project manager (M13 D4): the DaVinci-style library browser the app
|
|
//! shows at startup (no `--project` argument) and from 文件 → 项目管理器.
|
|
//!
|
|
//! The view is a modal content view ([`ProjectManager`]) hosted by
|
|
//! `crate::app::OakApp` inside the standard `Modal` card: a toolbar
|
|
//! (新建 / 导入), a column list of the library rows (name, modified time,
|
|
//! duration, tracks, clips, footage — the backend-derived stats), and an
|
|
//! action row (打开 / 重命名 / 复制 / 删除 / 导出). The view emits
|
|
//! [`ManagerEvent`] requests; the app routes them through the engine
|
|
//! ([`AppEngine`]'s library surface) and swaps in the rename / delete
|
|
//! confirmation modals.
|
|
//!
|
|
//! Pure formatting helpers ([`format_modified`], [`format_duration_ms`])
|
|
//! are unit tested here; the app-level flows are covered by the
|
|
//! `OakApp<MockEngine>` tests in `crate::app`.
|
|
|
|
use gpui::colors::DefaultColors;
|
|
use gpui::prelude::*;
|
|
use gpui::{
|
|
div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString,
|
|
Window,
|
|
};
|
|
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
|
|
|
use crate::i18n;
|
|
use crate::oakui::{AppEngine, LibraryProject};
|
|
|
|
/// A request the manager view emits for the host (the app shell) to route
|
|
/// through the engine.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ManagerEvent {
|
|
/// Open the project (double-click / the 打开 button).
|
|
Open(String),
|
|
/// Create a new blank project and open it.
|
|
Create,
|
|
/// Rename the project (the host prompts for the new name).
|
|
Rename(String),
|
|
/// Duplicate the project (history included).
|
|
Duplicate(String),
|
|
/// Delete the project (the host confirms first).
|
|
Delete(String),
|
|
/// Import a `.ove` / `.otio` / `.fcpxml` file as a new library row.
|
|
Import,
|
|
/// Export the project to a file.
|
|
Export(String),
|
|
}
|
|
|
|
/// The project manager content view: the library list plus its toolbars.
|
|
pub struct ProjectManager<E: AppEngine> {
|
|
engine: Entity<E>,
|
|
/// The listed rows (most recently modified first, as the engine
|
|
/// reports them).
|
|
rows: Vec<LibraryProject>,
|
|
/// The selected row index.
|
|
selected: Option<usize>,
|
|
/// The last operation error (shown under the list).
|
|
status: Option<String>,
|
|
}
|
|
|
|
impl<E: AppEngine> ProjectManager<E> {
|
|
/// Builds the view and loads the library.
|
|
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let mut this = Self {
|
|
engine,
|
|
rows: Vec::new(),
|
|
selected: None,
|
|
status: None,
|
|
};
|
|
this.reload(cx);
|
|
this
|
|
}
|
|
|
|
/// Reloads the library from the engine, keeping the selection on the
|
|
/// same row (by uuid) when it still exists.
|
|
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
|
let selected_uuid = self.selected_uuid();
|
|
match self.engine.read(cx).library_projects() {
|
|
Ok(rows) => {
|
|
self.rows = rows;
|
|
self.selected = selected_uuid
|
|
.and_then(|uuid| self.rows.iter().position(|row| row.uuid == uuid));
|
|
self.status = None;
|
|
}
|
|
Err(err) => {
|
|
self.rows = Vec::new();
|
|
self.selected = None;
|
|
self.status = Some(err);
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
/// The selected row's uuid, if any.
|
|
pub fn selected_uuid(&self) -> Option<String> {
|
|
self.selected
|
|
.and_then(|index| self.rows.get(index))
|
|
.map(|row| row.uuid.clone())
|
|
}
|
|
|
|
/// The selected row's display name, if any.
|
|
pub fn selected_name(&self) -> Option<String> {
|
|
self.selected
|
|
.and_then(|index| self.rows.get(index))
|
|
.map(|row| row.name.clone())
|
|
}
|
|
|
|
/// Shows an operation error under the list (the host reports engine
|
|
/// failures here so a failed action is visible in the dialog).
|
|
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
|
|
self.status = status;
|
|
cx.notify();
|
|
}
|
|
|
|
/// The listed rows (tests).
|
|
#[cfg(test)]
|
|
pub fn rows(&self) -> &[LibraryProject] {
|
|
&self.rows
|
|
}
|
|
|
|
/// Selects the row with `uuid` (tests and the host's post-action
|
|
/// reselection).
|
|
pub fn select(&mut self, uuid: &str, cx: &mut Context<Self>) {
|
|
self.selected = self.rows.iter().position(|row| row.uuid == uuid);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Emits the event through the view's subscribers.
|
|
fn emit(&mut self, event: ManagerEvent, cx: &mut Context<Self>) {
|
|
cx.emit(event);
|
|
cx.notify();
|
|
}
|
|
|
|
/// A click on row `index`: select, or open on a double click.
|
|
fn row_clicked(&mut self, index: usize, clicks: usize, cx: &mut Context<Self>) {
|
|
if index >= self.rows.len() {
|
|
return;
|
|
}
|
|
self.selected = Some(index);
|
|
if clicks >= 2 {
|
|
let uuid = self.rows[index].uuid.clone();
|
|
self.emit(ManagerEvent::Open(uuid), cx);
|
|
} else {
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// An action-row button targeting the selection.
|
|
fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context<Self>) {
|
|
if let Some(uuid) = self.selected_uuid() {
|
|
self.emit(action(uuid), cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<E: AppEngine> EventEmitter<ManagerEvent> for ProjectManager<E> {}
|
|
|
|
impl<E: AppEngine> Render for ProjectManager<E> {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let colors = cx.default_colors().clone();
|
|
|
|
// Toolbar buttons (新建 / 导入) and the selection-targeting action
|
|
// row (打开 / 重命名 / 复制 / 删除 / 导出).
|
|
let tool = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
|
div()
|
|
.id(id)
|
|
.px_3()
|
|
.py_1()
|
|
.rounded_md()
|
|
.bg(colors.background)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.text_color(colors.text)
|
|
.cursor_pointer()
|
|
.child(label)
|
|
};
|
|
let has_selection = self.selected.is_some();
|
|
let action = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
|
|
let mut b = div().id(id).px_3().py_1().rounded_md();
|
|
if has_selection {
|
|
b = b
|
|
.bg(colors.background)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.text_color(colors.text)
|
|
.cursor_pointer();
|
|
} else {
|
|
b = b.text_color(colors.disabled);
|
|
}
|
|
b.child(label)
|
|
};
|
|
|
|
// The column header.
|
|
let header_cell = |label: &'static str, width: f32| -> gpui::Div {
|
|
let mut cell = div()
|
|
.px_2()
|
|
.text_xs()
|
|
.text_color(colors.disabled)
|
|
.whitespace_nowrap()
|
|
.child(label);
|
|
if width <= 0.0 {
|
|
cell = cell.flex_1();
|
|
} else {
|
|
cell = cell.w(gpui::px(width)).text_right();
|
|
}
|
|
cell
|
|
};
|
|
|
|
let list = div()
|
|
.h(gpui::px(340.0))
|
|
.flex()
|
|
.flex_col()
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.rounded_md()
|
|
.bg(colors.background)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.py_1()
|
|
.border_b_1()
|
|
.border_color(colors.border)
|
|
.child(header_cell(i18n::tr("manager.col.name"), 0.0))
|
|
.child(header_cell(i18n::tr("manager.col.modified"), 128.0))
|
|
.child(header_cell(i18n::tr("manager.col.duration"), 72.0))
|
|
.child(header_cell(i18n::tr("manager.col.tracks"), 56.0))
|
|
.child(header_cell(i18n::tr("manager.col.clips"), 56.0))
|
|
.child(header_cell(i18n::tr("manager.col.footage"), 56.0)),
|
|
)
|
|
.child(
|
|
div()
|
|
.id("manager-list")
|
|
.flex_1()
|
|
.min_h_0()
|
|
.overflow_y_scroll()
|
|
.children(if self.rows.is_empty() {
|
|
vec![
|
|
div()
|
|
.p_4()
|
|
.text_color(colors.disabled)
|
|
.child(i18n::tr("manager.empty"))
|
|
.into_any_element(),
|
|
]
|
|
} else {
|
|
self.rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, row)| {
|
|
let selected = self.selected == Some(index);
|
|
let mut line = div()
|
|
.id(ElementId::Name(format!("manager-row-{index}").into()))
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.py_1()
|
|
.cursor_pointer()
|
|
.on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| {
|
|
this.row_clicked(index, event.click_count(), cx);
|
|
}));
|
|
if selected {
|
|
line = line.bg(colors.selected).text_color(colors.selected_text);
|
|
} else {
|
|
line = line.text_color(colors.text);
|
|
}
|
|
let cell = |text: String, width: f32| -> gpui::Div {
|
|
let mut c = div().px_2().whitespace_nowrap().child(text);
|
|
if width <= 0.0 {
|
|
c = c.flex_1();
|
|
} else {
|
|
c = c.w(gpui::px(width)).text_right();
|
|
}
|
|
c
|
|
};
|
|
line.child(cell(row.name.clone(), 0.0))
|
|
.child(cell(format_modified(row.modified_at), 128.0))
|
|
.child(cell(format_duration_ms(row.duration_ms), 72.0))
|
|
.child(cell(row.track_count.to_string(), 56.0))
|
|
.child(cell(row.clip_count.to_string(), 56.0))
|
|
.child(cell(row.footage_count.to_string(), 56.0))
|
|
.into_any_element()
|
|
})
|
|
.collect()
|
|
}),
|
|
);
|
|
|
|
let mut root = div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_3()
|
|
.w_full()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.gap_2()
|
|
.child(
|
|
tool("manager-new", i18n::tr("manager.new")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.emit(ManagerEvent::Create, cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
tool("manager-import", i18n::tr("manager.import")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.emit(ManagerEvent::Import, cx);
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
.child(list)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.justify_end()
|
|
.gap_2()
|
|
.child(
|
|
action("manager-open", i18n::tr("manager.open")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.selected_action(ManagerEvent::Open, cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
action("manager-rename", i18n::tr("manager.rename")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.selected_action(ManagerEvent::Rename, cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.selected_action(ManagerEvent::Duplicate, cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
action("manager-delete", i18n::tr("manager.delete")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.selected_action(ManagerEvent::Delete, cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
action("manager-export", i18n::tr("manager.export")).on_click(
|
|
cx.listener(|this, _e: &ClickEvent, _w, cx| {
|
|
this.selected_action(ManagerEvent::Export, cx);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
if let Some(status) = &self.status {
|
|
root = root.child(
|
|
div()
|
|
.text_xs()
|
|
.text_color(Hsla {
|
|
h: 0.0,
|
|
s: 0.6,
|
|
l: 0.55,
|
|
a: 1.0,
|
|
})
|
|
.child(status.clone()),
|
|
);
|
|
}
|
|
root
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rename prompt / delete confirmation contents
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The rename prompt's content: one text field with the new name.
|
|
pub struct NamePrompt {
|
|
editor: Entity<EditableTextState>,
|
|
}
|
|
|
|
impl NamePrompt {
|
|
/// Builds the prompt seeded with `initial`.
|
|
pub fn new(initial: &str, cx: &mut Context<Self>) -> Self {
|
|
let editor = cx.new(|cx| {
|
|
let editor = EditableTextState::new(StringStorage::default(), cx);
|
|
editor
|
|
});
|
|
editor.update(cx, |editor, cx| editor.emplace(initial, cx));
|
|
Self { editor }
|
|
}
|
|
|
|
/// The name currently entered (trimmed).
|
|
pub fn value(&self, app: &App) -> String {
|
|
self.editor.read(app).as_str().trim().to_string()
|
|
}
|
|
|
|
/// Replaces the entered name (tests / prefill).
|
|
pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
|
|
self.editor.update(cx, |editor, cx| editor.emplace(value, cx));
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
impl Render for NamePrompt {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let colors = cx.default_colors().clone();
|
|
let weak = self.editor.downgrade();
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.w_full()
|
|
.child(
|
|
div()
|
|
.text_color(colors.text)
|
|
.child(i18n::tr("manager.rename.label")),
|
|
)
|
|
.child(
|
|
div()
|
|
.rounded_md()
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.bg(colors.background)
|
|
.px_2()
|
|
.py_1()
|
|
.child(
|
|
text_input("gpui-widgets-rename-field")
|
|
.state(weak)
|
|
.accepts_input(true),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The delete confirmation's content: the warning text.
|
|
pub struct ConfirmContent {
|
|
text: SharedString,
|
|
}
|
|
|
|
impl ConfirmContent {
|
|
/// Builds the confirmation with `text`.
|
|
pub fn new(text: impl Into<SharedString>) -> Self {
|
|
Self { text: text.into() }
|
|
}
|
|
}
|
|
|
|
impl Render for ConfirmContent {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let colors = cx.default_colors().clone();
|
|
div().text_color(colors.text).child(self.text.clone())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Formatting helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Formats a unix timestamp (UTC) as `YYYY-MM-DD HH:MM` for the modified
|
|
/// column.
|
|
pub fn format_modified(unix_secs: i64) -> String {
|
|
if unix_secs <= 0 {
|
|
return "—".to_string();
|
|
}
|
|
let days = unix_secs.div_euclid(86_400);
|
|
let secs = unix_secs.rem_euclid(86_400);
|
|
let (year, month, day) = civil_from_days(days);
|
|
format!(
|
|
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
|
secs / 3600,
|
|
(secs % 3600) / 60
|
|
)
|
|
}
|
|
|
|
/// Days since the unix epoch → (year, month, day), UTC (Howard Hinnant's
|
|
/// civil-from-days algorithm).
|
|
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
|
let z = days + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z.rem_euclid(146_097);
|
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
|
let year = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
|
let year = if month <= 2 { year + 1 } else { year };
|
|
(year, month, day)
|
|
}
|
|
|
|
/// Formats a duration in milliseconds as `H:MM:SS` for the duration
|
|
/// column.
|
|
pub fn format_duration_ms(ms: i64) -> String {
|
|
if ms <= 0 {
|
|
return "—".to_string();
|
|
}
|
|
let secs = ms / 1000;
|
|
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The civil-date conversion matches known dates.
|
|
#[test]
|
|
fn format_modified_known_dates() {
|
|
assert_eq!(format_modified(0), "—");
|
|
assert_eq!(format_modified(-5), "—");
|
|
// 2026-08-16 00:54:01 UTC.
|
|
assert_eq!(format_modified(1_786_841_641), "2026-08-16 00:54");
|
|
// 1970-01-01 00:00 UTC.
|
|
assert_eq!(format_modified(1), "1970-01-01 00:00");
|
|
// 2000-02-29 (a leap day) 12:34 UTC.
|
|
assert_eq!(format_modified(951_827_640), "2000-02-29 12:34");
|
|
}
|
|
|
|
/// Durations format as H:MM:SS with a dash for empty projects.
|
|
#[test]
|
|
fn format_duration_ms_shapes() {
|
|
assert_eq!(format_duration_ms(0), "—");
|
|
assert_eq!(format_duration_ms(61_500), "0:01:01");
|
|
assert_eq!(format_duration_ms(3_725_000), "1:02:05");
|
|
}
|
|
}
|