23SFYK4Q5NKBPJG53PQNPWQH6UOUU2YKJEL7RLXYBRLJOJYV7AWQC 6YZAVBWU6E5FYOI5JGEIPXGZLIKAW6LS2AOFIQWEE5DMOPPCD5PQC IQDCHWCP47LL46EXQLQGHQPGFYIHQLMQBHA57RWJCIOX5UEUIQAQC SWWE2R6MVBX5CNM6X3WLXZTSRTU53PBJL7WJSFVF77XBPXDX4COAC WT3GA27PQ2AOAIGK65O3Q4DMX4AZDVNULBLRL6GF4QW6QCASUEAAC DVKSPF7R5QBWRHNADU7LK37OVZHOHNDSRETUVY6GNXWE74SHXSUAC UB2ITZJSDADVINSQEZ3HA6PVGA7OA6JYFG5GMSO7Y7LOXJC4FI7AC EC3TVL4X6VZZVLOKUN63LC73ADPHBHMZO7QMDXGX2ZPURVI4B4XQC KT5UYXGKEEXUHURNOYFVIG7WQ3Y3SJZMM2TP4OSW6NXSXQ5XXRHAC ELG3UDT6OJFEYSJR7HZEC65IUWBMGPPPCXEW3CDW5T74R6KC5LIAC S2NVIFXRFER4SRA37WCT5XTXHDHAL5WIGGKY4A4XOTPLTKTZSRGQC W7IUT3ZVMFH77IGKLAL7WX7IVVTGTY3FKEJ3WHMP3KI37B6NENLQC YBJRDOTCX3ZRDB5EVXJBR55FX3CADCSIGMYWNYVC2PD5W3GXR3DQC KM5PSZ4A2FJOPHJA6RC7LHZAUXLQDZDQC2DVSE5YUORLFIPZO74QC 2VUX5BTDKHX3TJ677NW34H5WLSWH35C3PU46C7MXCN5O7PAZVXNQC A5YBC77VWH2LXCZJOPZORQJI5ZYABSCHJWVX5HVNWPM5RABXESLQC D7A7MSIHJS3IAOLEPK52M4CZLDPLO7JB3Y62XACT2AM6UUCPQ6BAC UCBNZULEO6OIEV3RZCAPP6ICALAR7JIS2LLG7IRSX6PVYMFQT5AAC 4WO3ZJM2RNYZCBPS7FGYAEBELYD57OSS7LEUYCWGZBCAY272SNQQC BJXUYQ2YQMVULJITT5FEA6NERJVLWFKEAWSBYZVIB7KAT27KOWBAC CFYW3HGZN5O4IHGLUTUDYG7Y5FXDYPV4H2RIUA7R746PSHO7T32AC W4LFX7IHQ7SDX67ATSGWDB5IN6472ZJDBKY2XZ54SBJEYD5GAT5QC PTFDJ567XGGF26TE7KVQT7WPZIWV737DBO24VFIPEWPVAVKEKADQC AMPZ2BXK4IGUZO3OPBRSJ6Z4GI5K4PRFMLUGTR6AP4FKKRWQG7LQC FDDPOH5RRFK323PLIFJM2K2NWTSE2LW525YMHWJA6KIFPY2QVQNQC NOB64XMRXRLXGYS4JMGTFQMH5KMD7DHSTQ4YYBZ73E4PICW7QBQAC AXSXZQDGLPSLBYY3WEI5CCJFWFY33HPRVW6QQFL46OKWZH4G4YSAC V55EAIWQXWER2HWKZHPJBV7DDJMSPSPWSO3FSSAYODJHVDBHUN6QC Y5ATDI2HRWTTYJAVUR7SVWQVB4ZKKDZF3UVE4JJQFZ7RX7H7VPJQC UMO6U2ZTJ6LTWJXAKQMEJO2MCMFSJZJZ6UZ2SPY2FPU4OPVKPIMAC 6SW7UVSHRWJYE2PWVXULTUGEGD432T775EX6EKVEFRO3MDVVAG3AC B4RMW5AEGAJX5CFC4RFPI6Y3NBSDM7GZKNBPPTTICRZSDZSYNXHQC MJDGPSHGF62FTVWZBE7MFNJTUQD42OBVJEOSVPBT553UFJLTEMXQC ZVI4AWERNOTDJ3765HJXRBZT57XPNKVONQ6TGOGNPOL2VN42KMJQC QMAUTRB6R5R7ABWT2JIDEA7LMILZOS3PGPZIF3YUFKRVLW6HGKTQC VUIRSTKHQ3EG4XGGKR5RMIL5MRLVZVLR3DSGQXE2VDWJW3U2453AC OQ6HSAWHIRTAIIWMDGCTIOK47JDY7QVVAHLRDA2R5TTJKNSBFCWQC NWJD6VM6POMYKQTTPP3X6LVCWU3FHLDRIHMCSC2PPUT7JWNY42LAC AHWWRC73FXLSUDAJBU5UU76MZETHD3DSGJ7OLZPFEHXBDJ733QNAC UJPRF6DASB6TLFQSUZETOMCUVXIE5TXMCKWXMZR5SNE3IYCX4PMQC TEI5NQ3SCTU6JQIPU62B2AUXWRFSEU6DZYJG5T526G666VV5XXPAC DCSUCH6RRRQU4TQYO3K3HRC7SXAIBYP5R3ZOWAWS2LOXWNHEJM6AC O7PQIOJ3BIIOHXWLLO5SF263A4CW52HIU2BPL72DJ66X7KD3KEYAC UUB7SHLRIPT6JKE64MHEJBFCS5FHH5SN5R2OOK7TR2NVCPB6VN7AC JE44NYHM4QORCRKOF33QM42EDT7SBCPTULWGT6IVDL3D5LUHQXLAC 4ELJZGRJNL6FXB33QTYDNPY57JA3WZPUXKLQRTGSLDM7W65PD3YQC HC7ROIBC66IBYFED4ZZM7RXGSNC2CCBWBI36RKM2G5FD5DKVEYMQC FR52XEMWD22VH3GKSARXJUJXOGO7ZSQEHWPXFRWHLGRAJU3WRKCAC L6KSEFQIWICZJ6HJUFKLZQDEH6X2QMFM4Z7ZZUGMLDMFF7EHRXWAC BFN2VHZS7VCBUHQ4S3CQ3LFQV2V4M6VANNAF32XMRFQVWRGYSZ6AC VJNWIGSX5OIDOI27UYU7JAXM4X3KSPEQ37P5UXBLH5KHF3VBTZFQC GWZGYNIBQP2AA7WYULNBS2BCV4B36IHK4OS7XHVOTUUG27E76XFQC 3SYSJKYLVCXR54LRUPL6GOQISSJS6XWK4M6PRQRCKZN7F23NNVEAC use iced::Element;/// A short-named wrapper into `Element`#[inline(always)]pub fn el<'a, E, M>(e: E) -> Element<'a, M>whereE: Into<Element<'a, M>>,{Into::<Element<M>>::into(e)}
/// A short-named wrapper into `Element`#[inline(always)]pub fn el<'a, E, M>(e: E) -> Element<'a, M>whereE: Into<Element<'a, M>>,{Into::<Element<M>>::into(e)
use iced::{window, Theme};pub use util::el;pub fn theme<S>(_state: &S, _window_id: window::Id) -> Theme {Theme::TokyoNight
use std::cmp;use iced::widget::{column, container, row, text};use iced::{alignment, Background, Color, Element, Font, Length};use crate::el;// TODO: maybe use themeconst DELETED_BG_COLOR: Color = Color::from_rgba8(190, 37, 40, 0.15);const ADDED_BG_COLOR: Color = Color::from_rgba8(47, 148, 11, 0.15);#[derive(Debug, Default)]pub struct State {pub selected_sections: Vec<usize>,pub expanded_unchanged_sections: Vec<usize>,pub collapsed_changed_sections: Vec<usize>,}#[derive(Debug, Clone)]pub enum Msg {}/// [`File`] is not part of [`State`] so it can be stored separately (i.e. in a/// cache, where it's immutable once set, unlike [`State`] which can change with/// [`Action`]s)#[derive(Debug)]pub enum File {Decoded(DecodedFile),Undecodable(UndecodableFile),}#[derive(Debug)]pub struct DecodedFile {pub combined: Combined,pub diffs_without_contents: Vec<DiffWithoutContents>,}#[derive(Debug)]pub struct UndecodableFile {pub diffs_with_contents: Vec<DiffWithContents>,pub diffs_without_contents: Vec<DiffWithoutContents>,}/// A file combined with its diffs into sections#[derive(Debug)]pub struct Combined {pub sections: Vec<Section>,pub max_line_num: usize,}#[derive(Debug)]pub enum DiffWithContents {Add,Edit {line: usize,deleted: bool,contents: String,},Replacement {line: usize,/// Deleted linechange_contents: String,/// Added linesreplacement_contents: String,},Del,Undel,}#[derive(Debug)]pub enum DiffWithoutContents {// _________________________________________________________________________// Cases that never have contents:Move,SolveNameConflict,UnsolveNameConflict,SolveOrderConflict,UnsolveOrderConflict,ResurrectZombines,AddRoot,DelRoot,// _________________________________________________________________________// Cases that normally have contents, but in these cases the contents are// not decodable:Edit {line: usize,deleted: bool,contents: UndecodableContents,},Replacement {line: usize,/// Deleted linechange_contents: UndecodableContents,/// Added linesreplacement_contents: UndecodableContents,},}#[derive(Debug)]pub enum UndecodableContents {/// Short byte sequence of unknown encoding encoded with base64 for/// display. Must be shorter than [`crate::repo::MAX_LEN_BASE64_DISPLAY`]ShortBase64(String),UnknownEncoding,}#[derive(Debug)]pub enum Section {Unchanged(Lines),/// `deleted` and `added` are together because for/// `ChangedFileDiffWithContents::Replacement` they begin on the same line/// numberChanged {deleted: Lines,added: Lines,},}/// INVARIANT: There must be no new-lines in any of the strings, the source/// string must be split on those.pub type Lines = Vec<String>;pub fn view<'a>(state: Option<&'a State>, file: &'a File) -> Element<'a, Msg> {match file {File::Decoded(decoded_file) => view_decoded(state, decoded_file),File::Undecodable(undecodable_file) => {view_undecodable(state, undecodable_file)}}}pub fn contents_to_lines(contents: &str) -> Lines {contents.split('\n').map(str::to_string).collect()}fn view_decoded<'a>(_state: Option<&'a State>,file: &'a DecodedFile,) -> Element<'a, Msg> {let DecodedFile {combined,diffs_without_contents,} = file;let line_num_digits = combined.max_line_num.to_string().len();// TODO use state to display selection, and control section expansionlet mut current_line = 1;let sections_view = combined.sections.iter().map(|section| match section {Section::Unchanged(lines) => {let res = lines.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Unchanged,current_line + ix,line_num_digits,line,)});current_line += lines.len();el(column(res))}Section::Changed { deleted, added } => {let res = deleted.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Deleted,current_line + ix,line_num_digits,line,)}).chain(added.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Added,current_line + ix,line_num_digits,line,)}));current_line += added.len();el(column(res))}});if diffs_without_contents.is_empty() {el(column(sections_view))} else {let diffs_without_contents_view = diffs_without_contents.iter().map(view_diff_without_contents);el(column([el(column(diffs_without_contents_view)),el(column(sections_view)),]).spacing(10))}}fn view_undecodable<'a>(_state: Option<&'a State>,file: &'a UndecodableFile,) -> Element<'a, Msg> {let UndecodableFile {diffs_with_contents,diffs_without_contents,} = file;let diffs = diffs_with_contents.iter().map(view_diff_with_contents).chain(diffs_without_contents.iter().map(view_diff_without_contents),);el(column(diffs).spacing(10))}/// View diffs without context (the file contents)fn view_diff_with_contents(diff: &DiffWithContents) -> Element<'_, Msg> {match diff {DiffWithContents::Add => el(text("Added")),DiffWithContents::Edit {line,deleted,contents,} => {let line_num = *line;let lines = contents_to_lines(contents);let max_line_num = line_num + lines.len();let line_num_digits = max_line_num.to_string().len();let lines_view = lines.into_iter().enumerate().map(|(ix, line)| {line_view(if *deleted {LineKind::Deleted} else {LineKind::Added},line_num + ix,line_num_digits,line,)});el(column(lines_view))}DiffWithContents::Replacement {line,change_contents,replacement_contents,} => {let line_num = *line;let change_lines = contents_to_lines(change_contents);let replacement_lines = contents_to_lines(replacement_contents);let max_line_num = line_num+ cmp::max(change_lines.len(), replacement_lines.len());let line_num_digits = max_line_num.to_string().len();let lines_view = change_lines.into_iter().enumerate().map(|(ix, line)| {line_view(LineKind::Deleted,line_num + ix,line_num_digits,line,)}).chain(replacement_lines.into_iter().enumerate().map(|(ix, line)| {line_view(LineKind::Added,line_num + ix,line_num_digits,line,)},));el(column(lines_view))}DiffWithContents::Del => el(text("Deleted")),DiffWithContents::Undel => el(text("Revived")),}}/// View diffs without context (the file contents)fn view_diff_without_contents(diff: &DiffWithoutContents) -> Element<'_, Msg> {match diff {DiffWithoutContents::Move => el(text("Move")),DiffWithoutContents::SolveNameConflict => {el(text("Solve name conflict"))}DiffWithoutContents::UnsolveNameConflict => {el(text("Unsolve name conflict"))}DiffWithoutContents::SolveOrderConflict => {el(text("Solve order conflict"))}DiffWithoutContents::UnsolveOrderConflict => {el(text("Unsolve order conflict"))}DiffWithoutContents::ResurrectZombines => el(text("Resurrect zombies")),DiffWithoutContents::AddRoot => el(text("Add root")),DiffWithoutContents::DelRoot => el(text("Delete root")),DiffWithoutContents::Edit {line,deleted,contents,} => {let line_num = *line;let line = undecodable_contents_to_str(contents);line_view(if *deleted {LineKind::Deleted} else {LineKind::Added},line_num,1,line,)}DiffWithoutContents::Replacement {line,change_contents,replacement_contents,} => {let line_num = *line;let change_line = undecodable_contents_to_str(change_contents);let replacement_line =undecodable_contents_to_str(replacement_contents);el(column([line_view(LineKind::Deleted, line_num, 1, change_line),line_view(LineKind::Added, line_num, 1, replacement_line),]))}}}fn mono_text<'a>(txt: impl text::IntoFragment<'a>) -> iced::widget::Text<'a> {text(txt).font(Font::MONOSPACE).wrapping(text::Wrapping::WordOrGlyph).align_y(alignment::Vertical::Top)}fn line_num_view<'a>(num: usize, digits: usize) -> iced::widget::Text<'a> {// Fill the string to the number of digitslet txt = format!("{num:digits$} ");mono_text(txt).font(Font::MONOSPACE).style(move |theme| {let palette = theme.extended_palette();text::Style {color: Some(palette.background.base.text.scale_alpha(0.61)),}}).align_y(alignment::Vertical::Top)}#[derive(Debug, Clone, Copy)]enum LineKind {Unchanged,Added,Deleted,}fn line_view<'a>(kind: LineKind,line_num: usize,line_num_digits: usize,line: impl text::IntoFragment<'a>,) -> Element<'a, Msg> {let line = container(row([el(mono_text(match kind {LineKind::Unchanged => " ",LineKind::Added => "+ ",LineKind::Deleted => "- ",})),el(line_num_view(line_num, line_num_digits)),el(mono_text(line).width(Length::Fill)),]));el(match kind {LineKind::Unchanged => line,LineKind::Added => line.style(|_theme| {container::background(Background::from(ADDED_BG_COLOR))}),LineKind::Deleted => line.style(|_theme| {container::background(Background::from(DELETED_BG_COLOR))}),})}fn undecodable_contents_to_str(contents: &UndecodableContents) -> &str {match contents {UndecodableContents::ShortBase64(short) => short,UndecodableContents::UnknownEncoding => "Unknown encoding",}}
//! Main app viewuse crate::{diff, el};use iced::widget::{button, column, row, scrollable, text};use iced::{font, window, Border, Color, Element, Font, Length, Theme};use libflorescence::prelude::*;use libflorescence::repo;use iced::widget::text_editor;use std::collections::HashMap;use std::path::Path;const SPACING: u32 = 10;#[derive(Debug)]pub struct State<'a> {pub repo_path: &'a Path,pub repo: Option<&'a repo::State>,pub cursor: &'a cursor::State,pub record_msg: Option<&'a RecordMsg>,pub diffs_state: &'a HashMap<file::Id, diff::State>,}#[derive(Debug, Clone)]pub enum Msg {Cursor(cursor::Msg),ToRepo(repo::MsgIn),EditRecordMsg(text_editor::Action),DeferRecord,SaveRecord,AbandonRecord,FileDiffsContentsAction {id: file::Id,action: diff::Msg,},LogChangeFileDiffAction {hash: pijul::Hash,file: String,action: diff::Msg,},}pub mod file {use crate::diff;#[derive(Debug, Clone, Hash, PartialEq, Eq)]pub struct Id {pub path: String,pub file_kind: Kind,}#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]pub enum Kind {Untracked,Changed,}#[derive(Debug)]pub enum Diff {Loading,Loaded(diff::File),}}pub mod cursor {use crate::diff;use libflorescence::prelude::pijul;use std::collections::HashMap;#[derive(Debug, Clone)]pub enum Msg {Down,Up,Right,Left,Select(Select),}#[derive(Debug, Default)]pub struct State {pub selection: Option<Selection>,}#[derive(Debug)]pub enum Selection {UntrackedFile {ix: usize,path: String,},ChangedFile {ix: usize,path: String,},LogChange {ix: usize,hash: pijul::Hash,message: String,/// All the diffs in this change keyed by file path. Loaded async/// and set to None only while loading. The/// `diff::State` is also in here so that is it/// preserved while navigating between files.diffs: Option<HashMap<String, (diff::File, diff::State)>>,file: Option<LogChangeFileSelection>,},}#[derive(Debug)]pub struct LogChangeFileSelection {pub ix: usize,pub path: String,}#[derive(Debug, Clone)]pub enum Select {UntrackedFile {ix: usize,path: String,},ChangedFile {ix: usize,path: String,},LogChange {ix: usize,hash: pijul::Hash,message: String,},LogChangeFile {ix: usize,path: String,},}}#[derive(Debug)]pub enum RecordMsg {Typing(text_editor::Content),Canceled { old_msg: String },}pub fn view<'a, F>(state: State<'a>,_window_id: window::Id,get_file_diff: F,) -> Element<'a, Msg>whereF: Fn(&file::Id) -> Option<&'a file::Diff>,{if let Some(repo) = state.repo.as_ref() {let repo_info = el(row([el(text(&repo.dir_name)),el(text(": ")),el(button(text(&repo.channel)), /* TODO* .on_press(Message) */),]));let untracked_files = || {el(column(repo.untracked_files.iter().enumerate().map(|(ix, path)| {let is_selected = matches!(state.cursor.selection.as_ref() ,Some(cursor::Selection::UntrackedFile{ ix: selected_ix, .. }) if &ix == selected_ix);el(button(text(path)).on_press(Msg::Cursor(cursor::Msg::Select(cursor::Select::UntrackedFile{ix, path: path.clone()},))).style(selectable_button_style(is_selected)),)},)))};let changed_files = || {el(column(repo.changed_files.iter().enumerate().map(|(ix, (file_path, _diffs))| {let is_selected = matches!(state.cursor.selection.as_ref(),Some(cursor::Selection::ChangedFile{ ix: selected_ix, .. }) if &ix == selected_ix);el(button(text(file_path)).on_press(Msg::Cursor(cursor::Msg::Select(cursor::Select::ChangedFile{ix, path: file_path.clone()},))).style(selectable_button_style(is_selected)),)},)))};let log = || {el(column(repo.log.iter().enumerate().map(|(ix, repo::LogEntry {hash,message,file_paths: _,})| {let short_hash = display_short_hash(hash);let is_selected = matches!(state.cursor.selection.as_ref(),Some(cursor::Selection::LogChange { ix: selected_ix, .. }) if &ix == selected_ix);el(row([el(button(text(short_hash).font(Font::MONOSPACE)).on_press(Msg::Cursor(cursor::Msg::Select(cursor::Select::LogChange { ix, hash: *hash, message: message.clone() },))).style(selectable_button_style(is_selected))),el(text(message)),]).spacing(SPACING))},)))};let record_msg_editor = if let Some(RecordMsg::Typing(msg_content)) =state.record_msg.as_ref(){el(column([el(text_editor(msg_content).placeholder("Type something here...").on_action(Msg::EditRecordMsg)),el(row([el(button(text("Save")).on_press(Msg::SaveRecord)),el(button(text("Defer")).on_press(Msg::DeferRecord)),el(button(text("Abandon")).on_press(Msg::AbandonRecord)),])),]))} else {el(row([]))};let selection_details = match state.cursor.selection.as_ref() {Some(cursor::Selection::UntrackedFile { ix: _, path }) => {let id = file::Id {path: path.clone(),file_kind: file::Kind::Untracked,};let diffs = match get_file_diff(&id) {Some(file::Diff::Loaded(file)) => {let selection_state = state.diffs_state.get(&id);diff::view(selection_state, file).map(move |msg| {Msg::FileDiffsContentsAction {id: id.clone(),action: msg,}})}None | Some(file::Diff::Loading) => {el(text("Loading diff..."))}};el(column([view_diff_header(format!("Untracked file {path} contents:")),el(scrollable(diffs)),]).spacing(SPACING))}Some(cursor::Selection::ChangedFile { path, ix: _ }) => {let id = file::Id {path: path.clone(),file_kind: file::Kind::Changed,};let diffs = match get_file_diff(&id) {Some(file::Diff::Loaded(file)) => {let selection_state = state.diffs_state.get(&id);diff::view(selection_state, file).map(move |msg| {Msg::FileDiffsContentsAction {id: id.clone(),action: msg,}})}None | Some(file::Diff::Loading) => {el(text("Loading diff..."))}};el(column([view_diff_header(format!("Changed file {path} diff:")),el(scrollable(diffs)),]).spacing(SPACING))}Some(cursor::Selection::LogChange {ix,hash,message,diffs: _,file,}) => {let entry = state.repo.as_ref().unwrap().log.get(*ix).unwrap();let short_hash = display_short_hash(hash);let files = entry.file_paths.iter().enumerate().map(|(ix, path)| {let is_selected = matches!(file, Some(cursor::LogChangeFileSelection{ path: selected_path, .. }) if selected_path == path);el(button(text(path)).on_press_with(move || {Msg::Cursor(cursor::Msg::Select(cursor::Select::LogChangeFile { ix, path: path.clone() }))}).style(selectable_button_style(is_selected)))});el(column([view_diff_header(format!("{short_hash} message:")),el(text(message)),view_diff_header("Changed files:".to_string()),el(scrollable(column(files))),]).spacing(SPACING))}None => el(row([])),};let left_view = match state.cursor.selection.as_ref() {Some(cursor::Selection::LogChange {ix: _,hash,message: _,diffs,file: Some(cursor::LogChangeFileSelection { ix: _, path }),}) => el(column([view_diff_header(format!("{path} changes in {}:",display_short_hash(hash))),match diffs {Some(diffs) => {let (file, state) = diffs.get(path).unwrap();diff::view(Some(state), file).map(|action| {Msg::LogChangeFileDiffAction {hash: *hash,file: path.clone(),action,}})}None => el(text("Loading diff..")),},]).width(Length::FillPortion(1)).spacing(SPACING)),Some(cursor::Selection::UntrackedFile { .. })| Some(cursor::Selection::ChangedFile { .. })| Some(cursor::Selection::LogChange { .. })| None => el(column([repo_info,el(column([el(text("Untracked files:")), untracked_files()])),el(column([el(text("Changed files:")), changed_files()])),el(column([el(text("Recent changes:")), log()])),]).width(Length::FillPortion(1)).spacing(SPACING)),};let right_view = el(column([record_msg_editor, selection_details]).width(Length::FillPortion(1)));el(row([left_view, right_view]).spacing(SPACING))} else {el(text("Loading repo..."))}}fn view_diff_header(header: String) -> Element<'static, Msg> {el(text(header).font(Font {weight: font::Weight::Bold,..default()}))}fn selectable_button_style(is_selected: bool,) -> impl Fn(&Theme, button::Status) -> button::Style {move |theme, status| -> button::Style {button::Style {border: Border {color: if is_selected {Color::WHITE} else {Color::TRANSPARENT},width: 1.0,..default()},..button::Catalog::style(theme,&<Theme as button::Catalog>::default(),status,)}}}fn display_short_hash(hash: &pijul::Hash) -> String {let mut short_hash = pijul::Base32::to_base32(hash);short_hash.truncate(8);short_hash}
use iced::widget::{self, button, column, row, scrollable, text, text_editor};use iced::{font, window, Border, Color, Element, Font, Length, Subscription, Task,Theme,};
use iced::widget::text_editor;use iced::{widget, window, Element, Subscription, Task};
FileDiffsContentsAction {id: file::Id,action: diff::Action,},LogChangeFileDiffAction {hash: pijul::Hash,file: String,action: diff::Action,},
Message::WindowOpened(id) => Task::none(),Message::LoadedId(id) => {
Msg::View(msg) => update_from_app_view(state, msg),Msg::WindowOpened(_id) => Task::none(),Msg::LoadedId(id) => {
Message::Cursor(msg) => cursor::update(&mut state.cursor,&mut state.files,state.repo.as_ref(),msg,).map(Message::ToRepo),Message::AddUntrackedFile => {
Msg::AddUntrackedFile => {
Message::EditRecordMsg(action) => {
Msg::File(msg) => {file::update(&mut state.files, state.repo.as_ref(), msg);Task::none()}}}fn update_from_app_view(state: &mut State, msg: app::Msg) -> Task<Msg> {match msg {app::Msg::Cursor(msg) => cursor::update(&mut state.cursor,&mut state.files,state.repo.as_ref(),msg,).map(|msg| Msg::View(app::Msg::ToRepo(msg))),app::Msg::ToRepo(msg) => {state.repo_tx_in.send(msg).unwrap();Task::none()}app::Msg::EditRecordMsg(action) => {
"a" => Some(Message::AddUntrackedFile),"j" => Some(Message::Cursor(cursor::Msg::Down)),"k" => Some(Message::Cursor(cursor::Msg::Up)),"h" => Some(Message::Cursor(cursor::Msg::Left)),"l" => Some(Message::Cursor(cursor::Msg::Right)),"r" => Some(Message::Record),"x" => Some(Message::RmAddedFile),
"a" => Some(Msg::AddUntrackedFile),"j" => Some(Msg::View(app::Msg::Cursor(cursor::Msg::Down))),"k" => Some(Msg::View(app::Msg::Cursor(cursor::Msg::Up))),"h" => Some(Msg::View(app::Msg::Cursor(cursor::Msg::Left))),"l" => {Some(Msg::View(app::Msg::Cursor(cursor::Msg::Right)))}"r" => Some(Msg::Record),"x" => Some(Msg::RmAddedFile),
}fn theme(_state: &State, _window_id: window::Id) -> Theme {Theme::TokyoNight}fn view(state: &State, _window_id: window::Id) -> Element<Message> {if let Some(repo) = state.repo.as_ref() {let repo_info = el(row([el(text(&repo.dir_name)),el(text(": ")),el(button(text(&repo.channel)), /* TODO* .on_press(Message) */),]));let untracked_files = || {el(column(repo.untracked_files.iter().enumerate().map(|(ix, path)| {let is_selected = matches!(state.cursor.selection.as_ref() ,Some(cursor::Selection::UntrackedFile{ ix: selected_ix, .. }) if &ix == selected_ix);el(button(text(path)).on_press(Message::Cursor(cursor::Msg::Select(cursor::Select::UntrackedFile{ix, path: path.clone()},))).style(selectable_button_style(is_selected)),)},)))};let changed_files = || {el(column(repo.changed_files.iter().enumerate().map(|(ix, (file_path, _diffs))| {let is_selected = matches!(state.cursor.selection.as_ref(),Some(cursor::Selection::ChangedFile{ ix: selected_ix, .. }) if &ix == selected_ix);el(button(text(file_path)).on_press(Message::Cursor(cursor::Msg::Select(cursor::Select::ChangedFile{ix, path: file_path.clone()},))).style(selectable_button_style(is_selected)),)},)))};let log = || {el(column(repo.log.iter().enumerate().map(|(ix, repo::LogEntry {hash,message,file_paths: _,})| {let short_hash = display_short_hash(hash);let is_selected = matches!(state.cursor.selection.as_ref(),Some(cursor::Selection::LogChange { ix: selected_ix, .. }) if &ix == selected_ix);el(row([el(button(text(short_hash).font(Font::MONOSPACE)).on_press(Message::Cursor(cursor::Msg::Select(cursor::Select::LogChange { ix, hash: *hash, message: message.clone() },))).style(selectable_button_style(is_selected))),el(text(message)),]).spacing(SPACING))},)))};let record_msg_editor = if let Some(RecordMsg::Typing(msg_content)) =state.record_msg.as_ref(){el(column([el(text_editor(msg_content).placeholder("Type something here...").on_action(Message::EditRecordMsg)),el(row([el(button(text("Save")).on_press(Message::SaveRecord)),el(button(text("Defer")).on_press(Message::DeferRecord)),el(button(text("Abandon")).on_press(Message::AbandonRecord)),])),]))} else {el(row([]))};let selection_details = match state.cursor.selection.as_ref() {Some(cursor::Selection::UntrackedFile { ix: _, path }) => {let id = file::Id {path: path.clone(),file_kind: file::Kind::Untracked,};let diffs = match state.files.diffs_cache.inner.peek(&id) {Some(file::Diff::Loaded(file)) => {let selection_state = state.diffs_state.get(&id);diff::view(selection_state, file).map(move |msg| {Message::FileDiffsContentsAction {id: id.clone(),action: msg,}})}None | Some(file::Diff::Loading) => {el(text("Loading diff..."))}};el(column([view_diff_header(format!("Untracked file {path} contents:")),el(scrollable(diffs)),]).spacing(SPACING))}Some(cursor::Selection::ChangedFile { path, ix: _ }) => {let id = file::Id {path: path.clone(),file_kind: file::Kind::Changed,};let diffs = match state.files.diffs_cache.inner.peek(&id) {Some(file::Diff::Loaded(file)) => {let selection_state = state.diffs_state.get(&id);diff::view(selection_state, file).map(move |msg| {Message::FileDiffsContentsAction {id: id.clone(),action: msg,}})}None | Some(file::Diff::Loading) => {el(text("Loading diff..."))}};el(column([view_diff_header(format!("Changed file {path} diff:")),el(scrollable(diffs)),]).spacing(SPACING))}Some(cursor::Selection::LogChange {ix,hash,message,diffs: _,file,}) => {let entry = state.repo.as_ref().unwrap().log.get(*ix).unwrap();let short_hash = display_short_hash(hash);let files = entry.file_paths.iter().enumerate().map(|(ix, path)| {let is_selected = matches!(file, Some(cursor::LogChangeFileSelection{ path: selected_path, .. }) if selected_path == path);el(button(text(path)).on_press_with(move || {Message::Cursor(cursor::Msg::Select(cursor::Select::LogChangeFile { ix, path: path.clone() }))}).style(selectable_button_style(is_selected)))});el(column([view_diff_header(format!("{short_hash} message:")),el(text(message)),view_diff_header("Changed files:".to_string()),el(scrollable(column(files))),]).spacing(SPACING))}None => el(row([])),};let left_view = match state.cursor.selection.as_ref() {Some(cursor::Selection::LogChange {ix: _,hash,message: _,diffs,file: Some(cursor::LogChangeFileSelection { ix: _, path }),}) => el(column([view_diff_header(format!("{path} changes in {}:",display_short_hash(hash))),match diffs {Some(diffs) => {let (file, state) = diffs.get(path).unwrap();diff::view(Some(state), file).map(|action| {Message::LogChangeFileDiffAction {hash: *hash,file: path.clone(),action,}})}None => el(text("Loading diff..")),},]).width(Length::FillPortion(1)).spacing(SPACING)),Some(cursor::Selection::UntrackedFile { .. })| Some(cursor::Selection::ChangedFile { .. })| Some(cursor::Selection::LogChange { .. })| None => el(column([repo_info,el(column([el(text("Untracked files:")), untracked_files()])),el(column([el(text("Changed files:")), changed_files()])),el(column([el(text("Recent changes:")), log()])),]).width(Length::FillPortion(1)).spacing(SPACING)),};let right_view = el(column([record_msg_editor, selection_details]).width(Length::FillPortion(1)));el(row([left_view, right_view]).spacing(SPACING))} else {el(text("Loading repo..."))}}fn view_diff_header(header: String) -> Element<'static, Message> {el(text(header).font(Font {weight: font::Weight::Bold,..default()}))
fn selectable_button_style(is_selected: bool,) -> impl Fn(&Theme, button::Status) -> button::Style {move |theme, status| -> button::Style {button::Style {border: Border {color: if is_selected {Color::WHITE} else {Color::TRANSPARENT},width: 1.0,..default()},..button::Catalog::style(theme,&<Theme as button::Catalog>::default(),status,)}}
fn view(state: &State, window_id: window::Id) -> Element<Msg> {let State {id: _,repo_fs_watch: _,repo_path,repo_tx_in: _,repo,cursor,record_msg,files,diffs_state,} = state;app::view(app::State {repo_path,repo: repo.as_ref(),cursor,record_msg: record_msg.as_ref(),diffs_state,},window_id,|id| files.diffs_cache.inner.peek(id),).map(Msg::View)
fn display_short_hash(hash: &pijul::Hash) -> String {let mut short_hash = hash.to_base32();short_hash.truncate(8);short_hash}
#[derive(Debug, Clone)]pub enum Action {}pub fn update(_state: &mut State, _action: Action) {}pub fn view<'a>(state: Option<&'a State>,file: &'a File,) -> Element<'a, Action> {match file {File::Decoded(decoded_file) => view_decoded(state, decoded_file),File::Undecodable(undecodable_file) => {view_undecodable(state, undecodable_file)}}}/// [`File`] is not part of [`State`] so it can be stored separately (i.e. in a/// cache, where it's immutable once set, unlike [`State`] which can change with/// [`Action`]s)#[derive(Debug)]pub enum File {Decoded(DecodedFile),Undecodable(UndecodableFile),}
pub struct DecodedFile {pub combined: Combined,pub diffs_without_contents: Vec<DiffWithoutContents>,}#[derive(Debug)]pub struct UndecodableFile {pub diffs_with_contents: Vec<DiffWithContents>,pub diffs_without_contents: Vec<DiffWithoutContents>,}/// A file combined with its diffs into sections#[derive(Debug)]pub struct Combined {pub sections: Vec<Section>,pub max_line_num: usize,}#[derive(Debug)]
#[derive(Debug)]pub enum DiffWithoutContents {// _________________________________________________________________________// Cases that never have contents:Move,SolveNameConflict,UnsolveNameConflict,SolveOrderConflict,UnsolveOrderConflict,ResurrectZombines,AddRoot,DelRoot,// _________________________________________________________________________// Cases that normally have contents, but in these cases the contents are// not decodable:Edit {line: usize,deleted: bool,contents: UndecodableContents,},Replacement {line: usize,/// Deleted linechange_contents: UndecodableContents,/// Added linesreplacement_contents: UndecodableContents,},}#[derive(Debug)]pub enum UndecodableContents {/// Short byte sequence of unknown encoding encoded with base64 for/// display. Must be shorter than [`crate::repo::MAX_LEN_BASE64_DISPLAY`]ShortBase64(String),UnknownEncoding,}#[derive(Debug)]pub enum Section {Unchanged(Lines),/// `deleted` and `added` are together because for/// `ChangedFileDiffWithContents::Replacement` they begin on the same line/// numberChanged {deleted: Lines,added: Lines,},}/// INVARIANT: There must be no new-lines in any of the strings, the source/// string must be split on those.pub type Lines = Vec<String>;
}}fn contents_to_lines(contents: &str) -> Lines {contents.split('\n').map(str::to_string).collect()}fn undecodable_contents_to_str(contents: &UndecodableContents) -> &str {match contents {UndecodableContents::ShortBase64(short) => short,UndecodableContents::UnknownEncoding => "Unknown encoding",
fn view_decoded<'a>(_state: Option<&'a State>,file: &'a DecodedFile,) -> Element<'a, Action> {let DecodedFile {combined,diffs_without_contents,} = file;let line_num_digits = combined.max_line_num.to_string().len();// TODO use state to display selection, and control section expansionlet mut current_line = 1;let sections_view = combined.sections.iter().map(|section| match section {Section::Unchanged(lines) => {let res = lines.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Unchanged,current_line + ix,line_num_digits,line,)});current_line += lines.len();el(column(res))}Section::Changed { deleted, added } => {let res = deleted.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Deleted,current_line + ix,line_num_digits,line,)}).chain(added.iter().enumerate().map(move |(ix, line)| {line_view(LineKind::Added,current_line + ix,line_num_digits,line,)}));current_line += added.len();el(column(res))}});if diffs_without_contents.is_empty() {el(column(sections_view))} else {let diffs_without_contents_view = diffs_without_contents.iter().map(view_diff_without_contents);el(column([el(column(diffs_without_contents_view)),el(column(sections_view)),]).spacing(10))}}fn view_undecodable<'a>(_state: Option<&'a State>,file: &'a UndecodableFile,) -> Element<'a, Action> {let UndecodableFile {diffs_with_contents,diffs_without_contents,} = file;let diffs = diffs_with_contents.iter().map(view_diff_with_contents).chain(diffs_without_contents.iter().map(view_diff_without_contents),);el(column(diffs).spacing(10))}/// View diffs without context (the file contents)fn view_diff_with_contents(diff: &DiffWithContents) -> Element<'_, Action> {match diff {DiffWithContents::Add => el(text("Added")),DiffWithContents::Edit {line,deleted,contents,} => {let line_num = *line;let lines = contents_to_lines(contents);let max_line_num = line_num + lines.len();let line_num_digits = max_line_num.to_string().len();let lines_view = lines.into_iter().enumerate().map(|(ix, line)| {line_view(if *deleted {LineKind::Deleted} else {LineKind::Added},line_num + ix,line_num_digits,line,)});el(column(lines_view))}DiffWithContents::Replacement {line,change_contents,replacement_contents,} => {let line_num = *line;let change_lines = contents_to_lines(change_contents);let replacement_lines = contents_to_lines(replacement_contents);let max_line_num = line_num+ cmp::max(change_lines.len(), replacement_lines.len());let line_num_digits = max_line_num.to_string().len();let lines_view = change_lines.into_iter().enumerate().map(|(ix, line)| {line_view(LineKind::Deleted,line_num + ix,line_num_digits,line,)}).chain(replacement_lines.into_iter().enumerate().map(|(ix, line)| {line_view(LineKind::Added,line_num + ix,line_num_digits,line,)},));el(column(lines_view))}DiffWithContents::Del => el(text("Deleted")),DiffWithContents::Undel => el(text("Revived")),}}/// View diffs without context (the file contents)fn view_diff_without_contents(diff: &DiffWithoutContents,) -> Element<'_, Action> {match diff {DiffWithoutContents::Move => el(text("Move")),DiffWithoutContents::SolveNameConflict => {el(text("Solve name conflict"))}DiffWithoutContents::UnsolveNameConflict => {el(text("Unsolve name conflict"))}DiffWithoutContents::SolveOrderConflict => {el(text("Solve order conflict"))}DiffWithoutContents::UnsolveOrderConflict => {el(text("Unsolve order conflict"))}DiffWithoutContents::ResurrectZombines => el(text("Resurrect zombies")),DiffWithoutContents::AddRoot => el(text("Add root")),DiffWithoutContents::DelRoot => el(text("Delete root")),DiffWithoutContents::Edit {line,deleted,contents,} => {let line_num = *line;let line = undecodable_contents_to_str(contents);line_view(if *deleted {LineKind::Deleted} else {LineKind::Added},line_num,1,line,)}DiffWithoutContents::Replacement {line,change_contents,replacement_contents,} => {let line_num = *line;let change_line = undecodable_contents_to_str(change_contents);let replacement_line =undecodable_contents_to_str(replacement_contents);el(column([line_view(LineKind::Deleted, line_num, 1, change_line),line_view(LineKind::Added, line_num, 1, replacement_line),]))}}}fn mono_text<'a>(txt: impl text::IntoFragment<'a>) -> iced::widget::Text<'a> {text(txt).font(Font::MONOSPACE).wrapping(text::Wrapping::WordOrGlyph).align_y(alignment::Vertical::Top)}fn line_num_view<'a>(num: usize, digits: usize) -> iced::widget::Text<'a> {// Fill the string to the number of digitslet txt = format!("{num:digits$} ");mono_text(txt).font(Font::MONOSPACE).style(move |theme| {let palette = theme.extended_palette();text::Style {color: Some(palette.background.base.text.scale_alpha(0.61)),}}).align_y(alignment::Vertical::Top)}#[derive(Debug, Clone, Copy)]enum LineKind {Unchanged,Added,Deleted,}fn line_view<'a>(kind: LineKind,line_num: usize,line_num_digits: usize,line: impl text::IntoFragment<'a>,) -> Element<'a, Action> {let line = container(row([el(mono_text(match kind {LineKind::Unchanged => " ",LineKind::Added => "+ ",LineKind::Deleted => "- ",})),el(line_num_view(line_num, line_num_digits)),el(mono_text(line).width(Length::Fill)),]));el(match kind {LineKind::Unchanged => line,LineKind::Added => line.style(|_theme| {container::background(Background::from(ADDED_BG_COLOR))}),LineKind::Deleted => line.style(|_theme| {container::background(Background::from(DELETED_BG_COLOR))}),})}
#[derive(Debug, Default)]pub struct State {pub selection: Option<Selection>,}#[derive(Debug, Clone)]pub enum Msg {Down,Up,Right,Left,Select(Select),}#[derive(Debug)]pub enum Selection {UntrackedFile {ix: usize,path: String,},ChangedFile {ix: usize,path: String,},LogChange {ix: usize,hash: pijul::Hash,message: String,/// All the diffs in this change keyed by file path. Loaded async and/// set to None only while loading. The `diff::State` is also/// in here so that is it preserved while navigating between files.diffs: Option<HashMap<String, (diff::File, diff::State)>>,file: Option<LogChangeFileSelection>,},}#[derive(Debug)]pub struct LogChangeFileSelection {pub ix: usize,pub path: String,}
#[derive(Debug, Clone)]pub enum Select {UntrackedFile {ix: usize,path: String,},ChangedFile {ix: usize,path: String,},LogChange {ix: usize,hash: pijul::Hash,message: String,},LogChangeFile {ix: usize,path: String,},}