feat: first take at M2

This commit is contained in:
2026-04-04 18:04:39 +02:00
parent b532104032
commit eedd0a9118
33 changed files with 2899 additions and 78 deletions

View File

@@ -0,0 +1,5 @@
pub mod navigation;
pub mod tabs;
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
pub use tabs::{Tab, TabType};

View File

@@ -0,0 +1,126 @@
use std::fmt;
/// Which list the sidebar is currently showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarView {
Posts,
Pages,
Media,
Scripts,
Templates,
Tags,
Chat,
Import,
Git,
Settings,
}
impl fmt::Display for SidebarView {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let key = self.i18n_key();
f.write_str(key)
}
}
impl SidebarView {
/// Returns the `activity.*` i18n key for this sidebar view.
pub fn i18n_key(&self) -> &'static str {
match self {
Self::Posts => "activity.posts",
Self::Pages => "activity.pages",
Self::Media => "activity.media",
Self::Scripts => "activity.scripts",
Self::Templates => "activity.templates",
Self::Tags => "activity.tags",
Self::Chat => "activity.chat",
Self::Import => "activity.import",
Self::Git => "activity.git",
Self::Settings => "activity.settings",
}
}
}
/// Which tab is selected in the bottom panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelTab {
Tasks,
Output,
}
/// A snapshot of a running or completed task shown in the panel.
#[derive(Debug, Clone)]
pub struct TaskSnapshot {
pub id: u64,
pub label: String,
pub status: String,
pub progress: Option<f32>,
pub message: Option<String>,
}
/// A single line of output shown in the panel.
#[derive(Debug, Clone)]
pub struct OutputEntry {
pub timestamp: i64,
pub text: String,
}
/// Determine the next `(SidebarView, sidebar_visible)` after a user clicks
/// an activity-bar icon.
///
/// * Same icon + visible -> toggle sidebar off
/// * Same icon + hidden -> toggle sidebar on
/// * Different icon -> switch view and ensure visible
pub fn handle_activity_click(
current_view: SidebarView,
sidebar_visible: bool,
clicked: SidebarView,
) -> (SidebarView, bool) {
if clicked == current_view {
(current_view, !sidebar_visible)
} else {
(clicked, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_off_when_same_and_visible() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, true, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts);
assert!(!visible);
}
#[test]
fn toggle_on_when_same_and_hidden() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, false, SidebarView::Posts);
assert_eq!(view, SidebarView::Posts);
assert!(visible);
}
#[test]
fn switch_view_when_different() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, true, SidebarView::Media);
assert_eq!(view, SidebarView::Media);
assert!(visible);
}
#[test]
fn switch_view_when_different_and_hidden() {
let (view, visible) =
handle_activity_click(SidebarView::Posts, false, SidebarView::Settings);
assert_eq!(view, SidebarView::Settings);
assert!(visible);
}
#[test]
fn display_returns_i18n_key() {
assert_eq!(SidebarView::Posts.to_string(), "activity.posts");
assert_eq!(SidebarView::Settings.to_string(), "activity.settings");
}
}

View File

@@ -0,0 +1,161 @@
/// The kind of content a tab holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TabType {
Post,
Media,
Settings,
Style,
Tags,
Chat,
Import,
MenuEditor,
MetadataDiff,
Scripts,
Templates,
Documentation,
SiteValidation,
TranslationValidation,
}
impl TabType {
/// Singleton tabs may only appear once in the tab bar.
/// Every type except `Post` and `Media` is a singleton.
pub fn is_singleton(&self) -> bool {
!matches!(self, Self::Post | Self::Media)
}
}
/// A single tab in the editor area.
#[derive(Debug, Clone)]
pub struct Tab {
pub id: String,
pub tab_type: TabType,
pub title: String,
pub is_transient: bool,
}
/// Open (or focus) a tab in the tab list.
///
/// * **Singleton** — if a tab with the same `TabType` already exists, return
/// its index instead of inserting a duplicate.
/// * **Transient** — replace an existing transient tab of the same type, or
/// append if none exists.
/// * Otherwise — append unconditionally.
///
/// Returns the index of the resulting tab.
pub fn open_tab(tabs: &mut Vec<Tab>, new_tab: Tab) -> usize {
if new_tab.tab_type.is_singleton() {
if let Some(idx) = tabs.iter().position(|t| t.tab_type == new_tab.tab_type) {
return idx;
}
}
if new_tab.is_transient {
if let Some(idx) = tabs
.iter()
.position(|t| t.tab_type == new_tab.tab_type && t.is_transient)
{
tabs[idx] = new_tab;
return idx;
}
}
tabs.push(new_tab);
tabs.len() - 1
}
/// Remove the tab whose `id` matches and return the index that should be
/// selected next.
///
/// Prefers the same index (now occupied by the next tab), clamped to
/// `len - 1`. Returns `None` when the list is empty after removal.
pub fn close_tab(tabs: &mut Vec<Tab>, id: &str) -> Option<usize> {
let pos = tabs.iter().position(|t| t.id == id)?;
tabs.remove(pos);
if tabs.is_empty() {
None
} else {
Some(pos.min(tabs.len() - 1))
}
}
/// Mark a transient tab as permanent (non-transient).
pub fn pin_tab(tabs: &mut Vec<Tab>, id: &str) {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == id) {
tab.is_transient = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tab(id: &str, tab_type: TabType, transient: bool) -> Tab {
Tab {
id: id.to_string(),
tab_type,
title: id.to_string(),
is_transient: transient,
}
}
#[test]
fn singleton_dedup() {
let mut tabs = Vec::new();
let idx1 = open_tab(&mut tabs, make_tab("s1", TabType::Settings, false));
let idx2 = open_tab(&mut tabs, make_tab("s2", TabType::Settings, false));
assert_eq!(idx1, idx2);
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].id, "s1");
}
#[test]
fn transient_replacement() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
assert_eq!(tabs.len(), 1);
let idx = open_tab(&mut tabs, make_tab("p2", TabType::Post, true));
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[idx].id, "p2");
}
#[test]
fn pin_makes_permanent() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("p1", TabType::Post, true));
assert!(tabs[0].is_transient);
pin_tab(&mut tabs, "p1");
assert!(!tabs[0].is_transient);
}
#[test]
fn close_selects_neighbor() {
let mut tabs = Vec::new();
open_tab(&mut tabs, make_tab("a", TabType::Post, false));
open_tab(&mut tabs, make_tab("b", TabType::Media, false));
open_tab(&mut tabs, make_tab("c", TabType::Post, false));
// Close the middle tab.
let next = close_tab(&mut tabs, "b");
assert_eq!(next, Some(1)); // index 1 now holds "c"
assert_eq!(tabs[1].id, "c");
// Close the last tab.
let next = close_tab(&mut tabs, "c");
assert_eq!(next, Some(0)); // clamped to len-1
// Close the only remaining tab.
let next = close_tab(&mut tabs, "a");
assert_eq!(next, None);
}
#[test]
fn open_appends_non_singleton_non_transient() {
let mut tabs = Vec::new();
let i0 = open_tab(&mut tabs, make_tab("p1", TabType::Post, false));
let i1 = open_tab(&mut tabs, make_tab("p2", TabType::Post, false));
assert_eq!(i0, 0);
assert_eq!(i1, 1);
assert_eq!(tabs.len(), 2);
}
}