fix: database migration

This commit is contained in:
2026-04-05 12:47:58 +02:00
parent e0c0c8a31e
commit c7a939736c
59 changed files with 766 additions and 306 deletions

View File

@@ -127,8 +127,11 @@ pub fn view(
.padding(8)
.into()
} else {
// Per layout.allium: last 10 tasks, newest first
let items: Vec<Element<'static, Message>> = task_snapshots
.iter()
.rev()
.take(10)
.map(|snap| {
let progress_str = snap.progress
.map(|p| format!(" ({:.0}%)", p * 100.0))

View File

@@ -10,15 +10,10 @@ use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::tabs::{Tab, TabType};
/// Sidebar container style — dark background with right border separator.
/// Sidebar container style — dark background.
fn sidebar_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
border: Border {
color: Color::from_rgb(0.25, 0.25, 0.30),
width: 1.0,
radius: 0.0.into(),
},
..container::Style::default()
}
}
@@ -37,6 +32,20 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Sidebar item button style — active/selected.
fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32),
_ => Color::from_rgb(0.22, 0.22, 0.28),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border::default(),
..button::Style::default()
}
}
/// Get the appropriate empty-state message key for each sidebar view.
fn placeholder_key(view: SidebarView) -> &'static str {
match view {
@@ -53,10 +62,26 @@ fn placeholder_key(view: SidebarView) -> &'static str {
}
}
/// sidebar_views.allium media_title_max_length = 60
const MEDIA_TITLE_MAX_LEN: usize = 60;
/// Truncate a media title to the max length, appending "..." if over limit.
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
fn truncate_media_title(title: &str) -> String {
if title.chars().count() > MEDIA_TITLE_MAX_LEN {
let truncated: String = title.chars().take(MEDIA_TITLE_MAX_LEN).collect();
format!("{truncated}...")
} else {
title.to_string()
}
}
pub fn view(
sidebar_view: SidebarView,
posts: &[Post],
media: &[Media],
width: f32,
active_tab: Option<&str>,
locale: UiLocale,
) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key());
@@ -76,30 +101,77 @@ pub fn view(
.color(muted)
.into()
} else {
let items: Vec<Element<'static, Message>> = posts
.iter()
.map(|p| {
let status_indicator = match p.status {
bds_core::model::PostStatus::Draft => "\u{25CB} ", // ○
bds_core::model::PostStatus::Published => "\u{25CF} ", // ●
bds_core::model::PostStatus::Archived => "\u{25A1} ", // □
};
let label = format!("{status_indicator}{}", p.title);
button(text(label).size(12).shaping(Shaping::Advanced))
.on_press(Message::OpenTab(Tab {
id: p.id.clone(),
tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
let section_header = |label: &str| -> Element<'static, Message> {
text(label.to_string())
.size(11)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.55, 0.55, 0.60))
.into()
};
let make_post_item = |p: &Post| -> Element<'static, Message> {
let is_active = active_tab == Some(p.id.as_str());
let status_indicator = match p.status {
bds_core::model::PostStatus::Draft => "\u{25CB} ",
bds_core::model::PostStatus::Published => "\u{25CF} ",
bds_core::model::PostStatus::Archived => "\u{25A1} ",
};
let label = format!("{status_indicator}{}", p.title);
let label_text = text(label)
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill)
.style(item_style)
.into()
})
.collect();
iced::widget::Column::with_children(items)
.clip(true)
)
.on_press(Message::OpenTab(Tab {
id: p.id.clone(),
tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
};
let mut sections: Vec<Element<'static, Message>> = Vec::new();
// Draft section
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
if !drafts.is_empty() {
sections.push(section_header(&t(locale, "sidebar.drafts")));
for p in &drafts {
sections.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
}
// Published section
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
if !published.is_empty() {
sections.push(section_header(&t(locale, "sidebar.published")));
for p in &published {
sections.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
}
// Archived section
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
if !archived.is_empty() {
sections.push(section_header(&t(locale, "sidebar.archived")));
for p in &archived {
sections.push(make_post_item(p));
}
}
iced::widget::Column::with_children(sections)
.spacing(1)
.into()
}
@@ -115,20 +187,34 @@ pub fn view(
let items: Vec<Element<'static, Message>> = media
.iter()
.map(|m| {
let display_name = m.title.as_deref()
.unwrap_or(&m.original_name);
let is_active = active_tab == Some(m.id.as_str());
// Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..."
// if over limit; fallback originalName (no truncation).
let display_name = match m.title.as_deref() {
Some(title) => truncate_media_title(title),
None => m.original_name.clone(),
};
let label = format!("\u{1F5BC} {display_name}");
button(text(label).size(12).shaping(Shaping::Advanced))
let label_text = text(label)
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill)
.clip(true)
)
.on_press(Message::OpenTab(Tab {
id: m.id.clone(),
tab_type: TabType::Media,
title: display_name.to_string(),
title: display_name.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(item_style)
.style(style_fn)
.into()
})
.collect();
@@ -154,9 +240,41 @@ pub fn view(
.spacing(4)
.padding(12);
// layout.allium: sidebar width is resizable, passed as parameter
container(scrollable(content))
.width(Length::Fixed(280.0))
.width(Length::Fixed(width))
.height(Length::Fill)
.style(sidebar_style)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_media_title_short() {
assert_eq!(truncate_media_title("short title"), "short title");
}
#[test]
fn truncate_media_title_exact_60() {
let title: String = "a".repeat(60);
assert_eq!(truncate_media_title(&title), title);
}
#[test]
fn truncate_media_title_over_60() {
let title: String = "a".repeat(65);
let expected = format!("{}...", "a".repeat(60));
assert_eq!(truncate_media_title(&title), expected);
}
#[test]
fn truncate_media_title_unicode() {
// 61 Unicode chars should trigger truncation
let title: String = "\u{00FC}".repeat(61); // ü × 61
let expected = format!("{}...", "\u{00FC}".repeat(60));
assert_eq!(truncate_media_title(&title), expected);
}
}

View File

@@ -1,10 +1,20 @@
use iced::widget::{button, container, row, text, Space};
use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
use iced::widget::scrollable::Direction;
use iced::widget::text::Shaping;
use iced::widget::tooltip::Position;
use iced::{Background, Border, Color, Element, Font, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
use crate::state::tabs::Tab;
/// tabs.allium config: tab_min_width=100, tab_max_width=160
/// In a scrollable tab bar, tabs use the max width since they never need to shrink.
const TAB_WIDTH: f32 = 160.0;
const CHAT_TITLE_MAX_LEN: usize = 18;
/// Tab bar background.
fn bar_style(_theme: &Theme) -> container::Style {
container::Style {
@@ -64,9 +74,49 @@ fn close_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Tooltip style.
fn tooltip_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))),
border: Border {
color: Color::from_rgb(0.35, 0.35, 0.40),
width: 1.0,
radius: 4.0.into(),
},
..container::Style::default()
}
}
/// Truncate chat title per tabs.allium: chat_title_max_length = 18.
fn truncate_chat_title(title: &str) -> String {
if title.chars().count() > CHAT_TITLE_MAX_LEN {
let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect();
format!("{truncated}...")
} else {
title.to_string()
}
}
/// Build tooltip text per tabs.allium:
/// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified".
fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
let mut tip = tab.title.clone();
if tab.is_transient {
tip.push_str(" (");
tip.push_str(&t(locale, "tabBar.preview"));
tip.push(')');
}
if tab.is_dirty {
tip.push_str(" * ");
tip.push_str(&t(locale, "tabBar.modified"));
}
tip
}
pub fn view(
tabs: &[Tab],
active_tab: Option<&str>,
locale: UiLocale,
) -> Element<'static, Message> {
// Per tabs.allium: "Hidden when no tabs exist."
if tabs.is_empty() {
@@ -80,16 +130,25 @@ pub fn view(
let tab_id = tab.id.clone();
let close_id = tab.id.clone();
// Per tabs.allium: chat titles are JS-truncated to 18 chars + "..."
let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat {
truncate_chat_title(&tab.title)
} else {
tab.title.clone()
};
// Per tabs.allium: transient tabs show italic title
let title_label = if tab.is_transient {
text(tab.title.clone())
text(display_title)
.size(12)
.shaping(Shaping::Advanced)
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
.wrapping(iced::widget::text::Wrapping::None)
} else {
text(tab.title.clone())
text(display_title)
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None)
};
// Per tabs.allium DirtyIndicator: dot for dirty post tabs
@@ -100,32 +159,85 @@ pub fn view(
text("").size(10)
};
// Title + dirty indicator clipped to enforce ellipsis-like truncation
let title_area = container(
row![title_label, dirty_indicator]
.spacing(2)
.align_y(iced::Alignment::Center)
)
.width(Length::Fill)
.clip(true);
let label = row![
title_label,
dirty_indicator,
title_area,
button(text("\u{2715}").size(10).shaping(Shaping::Advanced))
.on_press(Message::CloseTab(close_id))
.padding(2)
.style(close_style),
]
.spacing(6)
.spacing(4)
.align_y(iced::Alignment::Center);
button(label)
// tabs.allium: tab_min_width=100, tab_max_width=160
let tab_btn = button(label)
.on_press(Message::SelectTab(tab_id))
.padding([6, 12])
.style(if is_active { tab_active } else { tab_inactive })
.into()
.padding([6, 8])
.width(Length::Fixed(TAB_WIDTH))
.style(if is_active { tab_active } else { tab_inactive });
// tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty
let tooltip_text = build_tooltip_text(tab, locale);
let tip: Element<'static, Message> = tooltip(
tab_btn,
text(tooltip_text).size(11).shaping(Shaping::Advanced),
Position::Bottom,
)
.gap(4)
.style(tooltip_style)
.into();
tip
})
.collect();
container(
iced::widget::Row::with_children(tab_buttons)
.spacing(1)
.height(Length::Fixed(35.0)),
)
.width(Length::Fill)
.height(Length::Fixed(35.0))
.style(bar_style)
.into()
// tabs.allium: horizontal strip with overflow scroll
let tab_row = iced::widget::Row::with_children(tab_buttons)
.spacing(1)
.height(Length::Fixed(35.0));
let scrollable_tabs = scrollable(tab_row)
.direction(Direction::Horizontal(
scrollable::Scrollbar::new().width(0).scroller_width(0),
))
.width(Length::Fill)
.height(Length::Fixed(35.0));
container(scrollable_tabs)
.width(Length::Fill)
.height(Length::Fixed(35.0))
.style(bar_style)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_chat_title_short() {
assert_eq!(truncate_chat_title("Hello"), "Hello");
}
#[test]
fn truncate_chat_title_exact_18() {
let title: String = "a".repeat(18);
assert_eq!(truncate_chat_title(&title), title);
}
#[test]
fn truncate_chat_title_over_18() {
let title: String = "a".repeat(25);
let expected = format!("{}...", "a".repeat(18));
assert_eq!(truncate_chat_title(&title), expected);
}
}

View File

@@ -1,4 +1,4 @@
use iced::widget::{button, column, container, row, stack, text, Space};
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
use iced::widget::text::Shaping;
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
@@ -19,15 +19,12 @@ fn content_bg(_theme: &Theme) -> container::Style {
}
}
/// Horizontal separator line between regions.
fn separator_v() -> iced::widget::Container<'static, Message> {
container(Space::new(0, 0))
.width(Length::Fixed(1.0))
.height(Length::Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
..container::Style::default()
})
/// Sidebar resize drag handle style.
fn drag_handle_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
..container::Style::default()
}
}
/// Horizontal line separator (full width).
@@ -46,6 +43,7 @@ pub fn view(
// Navigation
sidebar_view: SidebarView,
sidebar_visible: bool,
sidebar_width: f32,
// Tabs
tabs: &[Tab],
active_tab: Option<&str>,
@@ -76,7 +74,7 @@ pub fn view(
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
// Tab bar
let tabs_el = tab_bar::view(tabs, active_tab);
let tabs_el = tab_bar::view(tabs, active_tab, locale);
// Content area
let content_area = welcome::view(locale);
@@ -104,12 +102,25 @@ pub fn view(
.height(Length::Fill)
.style(content_bg);
// Main row: activity bar | separator | sidebar? | separator | right column
// Main row: activity bar | sidebar? | drag handle | right column
let mut main_row = row![activity];
if sidebar_visible {
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, locale));
main_row = main_row.push(separator_v());
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
// Resize drag handle: 4px wide strip between sidebar and content
let handle = container(Space::new(0, 0))
.width(Length::Fixed(4.0))
.height(Length::Fill)
.style(drag_handle_style);
let handle_hover = mouse_area(handle)
.on_press(Message::SidebarResizeStart)
.on_release(Message::SidebarResizeEnd)
.on_move(|point| Message::SidebarResizeMove(point.x))
.interaction(iced::mouse::Interaction::ResizingHorizontally);
main_row = main_row.push(handle_hover);
}
main_row = main_row.push(right);