From 07a50b63860698524280c9204bd001541194821b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 9 Sep 2026 15:22:46 -0400 Subject: [PATCH] Enable Rustolonia DevTools and improve system monitor layout and refresh --- Directory.Packages.props | 1 + .../.avalonia-viewmodel.owned.json | 4 +- apps/system-monitor/NeoHtop.App/src/main.rs | 253 +++++++++--------- .../src/monitoring/process_monitor.rs | 9 +- .../NeoHtop.App/src/reconcile.rs | 91 +++++++ .../Generated/.avalonia-viewmodel.owned.json | 4 +- .../Generated/MainViewModelAdapter.g.cs | 129 +++------ .../Generated/MainViewModelMetadata.g.cs | 2 +- .../Themes/WinUiFluent.axaml | 2 +- .../Views/MainWindow.axaml | 26 +- apps/system-monitor/README.md | 46 ++++ apps/system-monitor/build.ps1 | 6 +- apps/system-monitor/generated_view_models.rs | 33 +-- apps/system-monitor/view-model.contract.md | 2 +- apps/system-monitor/view-model.ir.json | 4 - host/Avalonia.Host.csproj | 5 + host/HostApplication.cs | 7 + rust/build-app.ps1 | 8 +- rust/package-shared.ps1 | 4 +- rust/tests/test-build-app.ps1 | 15 +- 20 files changed, 386 insertions(+), 265 deletions(-) create mode 100644 apps/system-monitor/NeoHtop.App/src/reconcile.rs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7bb5042..49bc153 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/apps/system-monitor/.avalonia-viewmodel.owned.json b/apps/system-monitor/.avalonia-viewmodel.owned.json index d157eed..bc8715b 100644 --- a/apps/system-monitor/.avalonia-viewmodel.owned.json +++ b/apps/system-monitor/.avalonia-viewmodel.owned.json @@ -3,11 +3,11 @@ "files": [ { "path": "generated_view_models.rs", - "sha256": "289f02ebb57648ad7d12e50ea355eac7d0365b710b96c65703e7be47a07cf45e" + "sha256": "e89b37605370eef2114245fb6c59f04fe19cab320974d94afe2f225c689c54a3" }, { "path": "view-model.contract.md", - "sha256": "3f7e5fe3d30761d8b32e319667673cc14ede1507e73dddd2dd34f7a26cb9e230" + "sha256": "af9fc463f555651093802264ac5e3fd000e4c53d96ffa07386239f5618cf6a81" } ] } diff --git a/apps/system-monitor/NeoHtop.App/src/main.rs b/apps/system-monitor/NeoHtop.App/src/main.rs index 8b025db..2084b36 100644 --- a/apps/system-monitor/NeoHtop.App/src/main.rs +++ b/apps/system-monitor/NeoHtop.App/src/main.rs @@ -22,6 +22,7 @@ mod filter; mod format; mod icon; mod monitoring; +mod reconcile; mod refresh; mod sort; @@ -112,6 +113,8 @@ struct MonitorState { processes: Vec, stats: SystemStats, visible: Vec, + published_keys: Vec, + published_rows: HashMap, search_text: String, filters: Filters, pinned: HashSet, @@ -132,7 +135,6 @@ struct MonitorState { show_search_help: bool, columns: ColumnVisibility, refresh_ms: u64, - window_generation: i64, batch_generation: i64, regex_cache: Mutex>>, } @@ -155,6 +157,8 @@ impl MonitorState { processes: Vec::new(), stats: SystemStats::default(), visible: Vec::new(), + published_keys: Vec::new(), + published_rows: HashMap::new(), search_text: String::new(), filters: Filters::default(), pinned: HashSet::new(), @@ -175,7 +179,6 @@ impl MonitorState { show_search_help: false, columns: ColumnVisibility::default(), refresh_ms: 3000, - window_generation: 1, batch_generation: 1, regex_cache: Mutex::new(HashMap::new()), } @@ -228,11 +231,6 @@ impl MonitorState { self.batch_generation } - fn bump_window(&mut self) -> i64 { - self.window_generation = self.window_generation.saturating_add(1); - self.window_generation - } - fn selected_process(&self) -> Option<&ProcessInfo> { self.visible .get(usize::try_from(self.selected_index).ok()?) @@ -274,8 +272,9 @@ impl MainViewModel for Model { { let mut state = self.state.lock().expect("monitor state lock poisoned"); let _ = state.collect(); - let generation = state.window_generation; - publish_snapshot(&mut state, &sink, generation, true)?; + state.published_keys.clear(); + state.published_rows.clear(); + publish_snapshot(&mut state, &sink)?; } { let mut shared = self.shared.lock().expect("shared lock poisoned"); @@ -297,15 +296,14 @@ impl MainViewModel for Model { fn set_search_text(&mut self, value: String) -> Result<()> { let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); state.search_text = value; state.rebuild_visible(); - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } @@ -437,7 +435,7 @@ impl MainViewModel for Model { fn refresh(&mut self) -> Result<()> { let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); if let Err(error) = state.collect() { if let Some(sink) = &sink { @@ -445,11 +443,10 @@ impl MainViewModel for Model { } return Ok(()); } - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } @@ -490,7 +487,7 @@ impl MainViewModel for Model { fn pin(&mut self, value: String) -> Result<()> { let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); let command = state .visible_process_by_key(&value) @@ -502,11 +499,10 @@ impl MainViewModel for Model { state.pinned.insert(command); } state.rebuild_visible(); - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } @@ -554,7 +550,7 @@ impl MainViewModel for Model { }, )?; let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); if state.sort_column == column { state.sort_descending = !state.sort_descending; @@ -563,18 +559,17 @@ impl MainViewModel for Model { state.sort_descending = true; } state.rebuild_visible(); - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } fn confirm_kill(&mut self) -> Result<()> { let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); state.show_kill_confirm = false; let Some(pending) = state.pending_kill.take() else { @@ -594,12 +589,11 @@ impl MainViewModel for Model { return Ok(()); } let _ = state.collect(); - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); sink.set_show_kill_confirm(false)?; - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } @@ -867,47 +861,6 @@ impl MainViewModel for Model { self.set_overlay(Overlay::None, false) } - fn request_processes_range(&mut self, request: RangeRequest) -> Result<()> { - let Some(sink) = self.sink.clone() else { - return Ok(()); - }; - if !sink.supports_richer_shapes() { - return Ok(()); - } - let Some(mut page) = sink.processes_page(request.offset) else { - return Ok(()); - }; - if page.generation() != request.generation { - return Ok(()); - } - let rows = { - let state = self.state.lock().expect("monitor state lock poisoned"); - if state.window_generation != request.generation { - return Ok(()); - } - let start = request.offset.max(0) as usize; - let end = (start + request.length.max(0) as usize).min(state.visible.len()); - state.visible[start.min(end)..end] - .iter() - .filter_map(|&index| state.processes.get(index).cloned()) - .map(|process| { - let pinned = state.pinned.contains(&process.command); - let high_usage = process.cpu_usage > 50.0 - || (state.stats.memory_total > 0 - && process.memory_usage * 10 > state.stats.memory_total); - RowModel { - process, - pinned, - high_usage, - } - }) - .collect::>() - }; - for row in rows { - sink.push_processes_row(&mut page, row); - } - sink.publish_processes_page(page).map(|_| ()) - } } #[derive(Clone, Copy)] @@ -949,15 +902,14 @@ impl Model { fn update_filter(&mut self, update: impl FnOnce(&mut Filters)) -> Result<()> { let sink = self.sink.clone(); - let generation = { + { let mut state = self.state.lock().expect("monitor state lock poisoned"); update(&mut state.filters); state.rebuild_visible(); - state.bump_window() }; if let Some(sink) = sink { let mut state = self.state.lock().expect("monitor state lock poisoned"); - publish_snapshot(&mut state, &sink, generation, true)?; + publish_snapshot(&mut state, &sink)?; } Ok(()) } @@ -983,18 +935,7 @@ fn start_refresh_thread( if state.is_frozen { None } else { - let previous = state.visible.len(); - if let Err(error) = state.collect() { - Some(Err(error)) - } else { - let reset = state.visible.len() != previous; - let generation = if reset { - state.bump_window() - } else { - state.window_generation - }; - Some(Ok((generation, reset))) - } + Some(state.collect()) } }; match outcome { @@ -1002,9 +943,9 @@ fn start_refresh_thread( Some(Err(error)) => { let _ = sink.set_error_message(Some(error)); } - Some(Ok((generation, reset))) => { + Some(Ok(())) => { let mut state = state.lock().expect("monitor state lock poisoned"); - let _ = publish_snapshot(&mut state, &sink, generation, reset); + let _ = publish_snapshot(&mut state, &sink); } } }, @@ -1014,8 +955,6 @@ fn start_refresh_thread( fn publish_snapshot( state: &mut MonitorState, sink: &MainViewModelSink, - generation: i64, - reset_window: bool, ) -> Result<()> { let cpu_avg = if state.stats.cpu_usage.is_empty() { 0.0 @@ -1026,7 +965,6 @@ fn publish_snapshot( let storage_percent = format::percent(state.stats.disk_used_bytes, state.stats.disk_total_bytes); let mut batch = sink.batch(state.next_batch_generation()); - // Use a unique generation even when we don't bump the window. batch.set_search_text(&state.search_text); batch.set_is_frozen(state.is_frozen); batch.set_is_dark_theme(state.is_dark_theme); @@ -1135,14 +1073,38 @@ fn publish_snapshot( batch.set_memory_percent_label(format!("{memory_percent:.1}%")); batch.set_storage_percent_label(format!("{storage_percent:.1}%")); batch.replace_cpu_cores_snapshot(core_rows(&state.stats.cpu_usage)); - sink.submit_batch(batch)?; - if sink.supports_richer_shapes() { - if reset_window { - sink.reset_processes(generation, state.visible.len() as i64)?; + let desired: Vec<_> = state + .visible + .iter() + .map(|&index| process_key(&state.processes[index])) + .collect(); + for (&index, key) in state.visible.iter().zip(&desired) { + let process = &state.processes[index]; + let values = RowValues { + process: process.clone(), + pinned: state.pinned.contains(&process.command), + high_usage: process.cpu_usage > 50.0 + || (state.stats.memory_total > 0 + && process.memory_usage > state.stats.memory_total / 10), + }; + if let Some(row) = state.published_rows.get(key) { + row.update(values)?; } else { - sink.refresh_processes()?; + state.published_rows.insert(key.clone(), RowModel::new(values)); } } + for change in reconcile::reconcile(&mut state.published_keys, &desired) { + match change { + reconcile::Change::Remove(index) => batch.remove_processes(index as i32), + reconcile::Change::Insert(index) => { + batch.insert_processes(index as i32, state.published_rows[&desired[index]].clone()); + } + reconcile::Change::Move { from, to } => batch.move_processes(from as i32, to as i32), + } + } + let wanted: HashSet<_> = desired.iter().collect(); + state.published_rows.retain(|key, _| wanted.contains(key)); + sink.submit_batch(batch)?; Ok(()) } @@ -1258,56 +1220,97 @@ impl CpuCoreViewModel for CoreRow { } } -struct RowModel { +struct RowValues { process: ProcessInfo, pinned: bool, high_usage: bool, } +struct RowState { + values: RowValues, + sink: Option, + generation: i64, +} + +#[derive(Clone)] +struct RowModel(Arc>); + +impl RowModel { + fn new(values: RowValues) -> Self { + Self(Arc::new(Mutex::new(RowState { + values, + sink: None, + generation: 1, + }))) + } + + fn update(&self, values: RowValues) -> Result<()> { + let mut state = self.0.lock().expect("process row lock poisoned"); + state.values = values; + state.generation = state.generation.saturating_add(1); + if let Some(sink) = &state.sink { + state.values.publish(sink, state.generation)?; + } + Ok(()) + } +} + impl ProcessRowViewModel for RowModel { fn attach(&mut self, sink: ProcessRowViewModelSink) -> Result<()> { + let mut state = self.0.lock().expect("process row lock poisoned"); + state.values.publish(&sink, state.generation)?; + state.sink = Some(sink); + Ok(()) + } + + fn detach(&mut self) -> Result<()> { + self.0.lock().expect("process row lock poisoned").sink = None; + Ok(()) + } +} + +impl RowValues { + fn publish(&self, sink: &ProcessRowViewModelSink, generation: i64) -> Result<()> { let process = &self.process; - sink.set_name(&process.name)?; - sink.set_pid(process.pid as i64)?; - sink.set_ppid(process.ppid as i64)?; - sink.set_status(&process.status)?; - sink.set_user(&process.user)?; - sink.set_cpu_usage(f64::from(process.cpu_usage))?; - sink.set_memory_usage(process.memory_usage as i64)?; - sink.set_virtual_memory(process.virtual_memory as i64)?; - sink.set_disk_read(process.disk_usage.0 as i64)?; - sink.set_disk_write(process.disk_usage.1 as i64)?; - sink.set_command(&process.command)?; - sink.set_root(&process.root)?; - sink.set_session_id(process.session_id.unwrap_or(0) as i64)?; - sink.set_start_time(process.start_time as i64)?; - sink.set_run_time(process.run_time as i64)?; - sink.set_is_pinned(self.pinned)?; - sink.set_key(process_key(process))?; - sink.set_environ(process.environ.join("; "))?; - sink.set_disk_io(format::disk_io(process.disk_usage.0, process.disk_usage.1))?; - sink.set_run_time_label(format::runtime(process.run_time))?; - sink.set_memory_label(format::bytes(process.memory_usage))?; - sink.set_virtual_memory_label(format::bytes(process.virtual_memory))?; - sink.set_cpu_label(format::cpu(process.cpu_usage))?; - sink.set_pin_label(if self.pinned { "Unpin" } else { "Pin" })?; - sink.set_is_high_usage(self.high_usage)?; - sink.set_start_time_label(format::start_time(process.start_time))?; - sink.set_session_label( + let mut batch = sink.batch(generation); + batch.set_name(&process.name); + batch.set_pid(process.pid as i64); + batch.set_ppid(process.ppid as i64); + batch.set_status(&process.status); + batch.set_user(&process.user); + batch.set_cpu_usage(f64::from(process.cpu_usage)); + batch.set_memory_usage(process.memory_usage as i64); + batch.set_virtual_memory(process.virtual_memory as i64); + batch.set_disk_read(process.disk_usage.0 as i64); + batch.set_disk_write(process.disk_usage.1 as i64); + batch.set_command(&process.command); + batch.set_root(&process.root); + batch.set_session_id(process.session_id.unwrap_or(0) as i64); + batch.set_start_time(process.start_time as i64); + batch.set_run_time(process.run_time as i64); + batch.set_is_pinned(self.pinned); + batch.set_key(process_key(process)); + batch.set_environ(process.environ.join("; ")); + batch.set_disk_io(format::disk_io(process.disk_usage.0, process.disk_usage.1)); + batch.set_run_time_label(format::runtime(process.run_time)); + batch.set_memory_label(format::bytes(process.memory_usage)); + batch.set_virtual_memory_label(format::bytes(process.virtual_memory)); + batch.set_cpu_label(format::cpu(process.cpu_usage)); + batch.set_pin_label(if self.pinned { "Unpin" } else { "Pin" }); + batch.set_is_high_usage(self.high_usage); + batch.set_start_time_label(format::start_time(process.start_time)); + batch.set_session_label( process .session_id .map(|id| id.to_string()) .unwrap_or_else(|| "-".to_owned()), - )?; - sink.set_icon_png(crate::icon::png_for_process( + ); + batch.set_icon_png(crate::icon::png_for_process( process.pid, &process.exe_path, &process.command, - )) - } - - fn detach(&mut self) -> Result<()> { - Ok(()) + )); + sink.submit_batch(batch).map(|_| ()) } } diff --git a/apps/system-monitor/NeoHtop.App/src/monitoring/process_monitor.rs b/apps/system-monitor/NeoHtop.App/src/monitoring/process_monitor.rs index 54c96ae..bb9175d 100644 --- a/apps/system-monitor/NeoHtop.App/src/monitoring/process_monitor.rs +++ b/apps/system-monitor/NeoHtop.App/src/monitoring/process_monitor.rs @@ -42,12 +42,14 @@ fn os_string_vec_to_string_vec(v: &[OsString]) -> Vec { #[derive(Debug)] pub struct ProcessMonitor { process_cache: HashMap<(u32, u64), ProcessStaticInfo>, + users: sysinfo::Users, } impl ProcessMonitor { pub fn new() -> Self { Self { process_cache: HashMap::new(), + users: sysinfo::Users::new_with_refreshed_list(), } } @@ -123,7 +125,12 @@ impl ProcessMonitor { identity: Self::process_identity(pid, start_time), name: process.name().to_string_lossy().into_owned(), cmd: os_string_vec_to_string_vec(process.cmd()), - user_id: process.user_id().map(|uid| uid.to_string()), + user_id: process.user_id().map(|uid| { + self.users + .get_user_by_id(uid) + .map(|user| user.name().to_owned()) + .unwrap_or_else(|| uid.to_string()) + }), cpu_usage: process.cpu_usage(), memory: process.memory(), status: process.status(), diff --git a/apps/system-monitor/NeoHtop.App/src/reconcile.rs b/apps/system-monitor/NeoHtop.App/src/reconcile.rs new file mode 100644 index 0000000..524f7cb --- /dev/null +++ b/apps/system-monitor/NeoHtop.App/src/reconcile.rs @@ -0,0 +1,91 @@ +use std::collections::HashSet; + +#[derive(Debug, PartialEq)] +pub enum Change { + Remove(usize), + Insert(usize), + Move { from: usize, to: usize }, +} + +/// Reconciles unique process identities without resetting surviving rows. +pub fn reconcile(current: &mut Vec, desired: &[String]) -> Vec { + let wanted: HashSet<_> = desired.iter().collect(); + let mut changes = Vec::new(); + for index in (0..current.len()).rev() { + if !wanted.contains(¤t[index]) { + current.remove(index); + changes.push(Change::Remove(index)); + } + } + for (index, key) in desired.iter().enumerate() { + if current.get(index) == Some(key) { + continue; + } + if let Some(from) = current.iter().position(|candidate| candidate == key) { + let key = current.remove(from); + current.insert(index, key); + changes.push(Change::Move { from, to: index }); + } else { + current.insert(index, key.clone()); + changes.push(Change::Insert(index)); + } + } + changes +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn unchanged_refresh_does_not_touch_the_collection() { + let mut current = keys(&["1:100", "2:200"]); + let desired = current.clone(); + assert!(reconcile(&mut current, &desired).is_empty()); + } + + #[test] + fn sorting_moves_existing_rows_instead_of_replacing_them() { + let mut current = keys(&["a", "b", "c"]); + let desired = keys(&["c", "a", "b"]); + assert_eq!( + reconcile(&mut current, &desired), + vec![Change::Move { from: 2, to: 0 }] + ); + assert_eq!(current, desired); + } + + #[test] + fn count_changes_and_pid_reuse_only_replace_affected_identities() { + let mut current = keys(&["1:100", "2:200", "3:300"]); + let desired = keys(&["3:300", "2:201", "4:400", "5:500"]); + assert_eq!( + reconcile(&mut current, &desired), + vec![ + Change::Remove(1), + Change::Remove(0), + Change::Insert(1), + Change::Insert(2), + Change::Insert(3), + ] + ); + assert_eq!(current, desired); + } + + #[test] + fn filtering_to_empty_and_back_is_incremental() { + let mut current = keys(&["a", "b"]); + assert_eq!( + reconcile(&mut current, &[]), + vec![Change::Remove(1), Change::Remove(0)] + ); + assert_eq!( + reconcile(&mut current, &keys(&["b"])), + vec![Change::Insert(0)] + ); + } +} diff --git a/apps/system-monitor/NeoHtop.Presentation/Generated/.avalonia-viewmodel.owned.json b/apps/system-monitor/NeoHtop.Presentation/Generated/.avalonia-viewmodel.owned.json index 32a557b..3330644 100644 --- a/apps/system-monitor/NeoHtop.Presentation/Generated/.avalonia-viewmodel.owned.json +++ b/apps/system-monitor/NeoHtop.Presentation/Generated/.avalonia-viewmodel.owned.json @@ -11,7 +11,7 @@ }, { "path": "MainViewModelAdapter.g.cs", - "sha256": "4c6186b5d1a5e9f4240eb76d7d92e7d1e9c899d454e025922070a5c2c50ae8fd" + "sha256": "76284340a29f17a1f3d694489a0d1e7371c322b69f25b0e666e56baafe6bbcad" }, { "path": "MainViewModelMenus.g.cs", @@ -19,7 +19,7 @@ }, { "path": "MainViewModelMetadata.g.cs", - "sha256": "c3c728088b40b0c889320092ab45bba0235e9e94bdb78ab234693d23c030f2db" + "sha256": "3bdcd3b112da520952442d86a5364e8f35856b88fa6f050cfaa6b8aaa0b735b5" }, { "path": "ProcessRowViewModelAdapter.g.cs", diff --git a/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelAdapter.g.cs b/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelAdapter.g.cs index 129a60f..7f65666 100644 --- a/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelAdapter.g.cs +++ b/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelAdapter.g.cs @@ -20,7 +20,7 @@ namespace NeoHtop.Presentation.Generated; [GeneratedComClass] -public sealed partial class MainViewModelAdapter : IAvnRustVmSink, IAvnRustVmSink2, IAvnRustVmSink3, IAvnRustVmSink4, IRustVmStringSnapshotSink, IRustVmModelSnapshotSink, IRustVmBatchTarget, IRustVmTableSelectionBatchTarget, INotifyPropertyChanged, INotifyDataErrorInfo, IDisposable +public sealed partial class MainViewModelAdapter : IAvnRustVmSink, IAvnRustVmSink2, IAvnRustVmSink3, IRustVmStringSnapshotSink, IRustVmModelSnapshotSink, IRustVmBatchTarget, IRustVmTableSelectionBatchTarget, INotifyPropertyChanged, INotifyDataErrorInfo, IDisposable { private readonly IAvnRustViewModel _model; private readonly Action _dispatch; @@ -28,7 +28,6 @@ public sealed partial class MainViewModelAdapter : IAvnRustVmSink, IAvnRustVmSin private readonly RustVmBatchCoordinator _batch; private readonly Dictionary _errors = new(StringComparer.Ordinal); private readonly RustVmInboundWriteTracker _inboundWrites = new(); - private readonly RustRangeCoordinator _ranges; private string _searchText = ""; private bool _isFrozen = false; private bool _isDarkTheme = true; @@ -144,11 +143,6 @@ public MainViewModelAdapter(IAvnRustViewModel model, Action? dispatch, A _dispatch = dispatch ?? Dispatch; _post = post; _batch = new RustVmBatchCoordinator(this, post); - _ranges = new RustRangeCoordinator(ResolveWindow, post); - var rangeSource = RustAsyncCommands.TryResolveRangeSource(model); - Processes = new RustWindowedCollection(1, 64, 8, (nested, _) => new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(nested!, _dispatch, _post)); - Processes.SetSource(rangeSource); - Processes.SetPeerResolver(ResolveWindow); RefreshCommand = new DelegateCommand(parameter => Check(_model.Execute(1, null))); KillCommand = new DelegateCommand(parameter => Check(_model.BeginAsync(2, CommandArgumentOrFallback(parameter, SelectedKey)))); PinCommand = new DelegateCommand(parameter => Check(_model.Execute(3, CommandArgumentOrFallback(parameter, SelectedKey)))); @@ -174,9 +168,6 @@ public MainViewModelAdapter(IAvnRustViewModel model, Action? dispatch, A try { Check(_model.Attach(this)); - // Primed after attach: a producer publishes its dataset identity - // from attach, so reading it before would always come back empty. - PrimeWindows(rangeSource); } catch { @@ -1575,12 +1566,8 @@ public string StoragePercentLabel get => _storagePercentLabel; } + public BatchObservableCollection Processes { get; } = []; public BatchObservableCollection CpuCores { get; } = []; - /// - /// Range-backed projection: Count is the Rust dataset's total size while at - /// most 64 x 8 element objects are live. - /// - public RustWindowedCollection Processes { get; } public DelegateCommand RefreshCommand { get; } public DelegateCommand KillCommand { get; } @@ -1758,6 +1745,7 @@ public int SetNull(int propertyId) public int AddModel(int collectionId, IAvnRustViewModel? model) => collectionId switch { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => Processes.Add(new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(model, _dispatch, _post))), 2 => model is null ? unchecked((int)0x80070057) : Apply(() => CpuCores.Add(new global::NeoHtop.Presentation.Generated.CpuCoreViewModelAdapter(model, _dispatch, _post))), _ => unchecked((int)0x80070057), }; @@ -1769,6 +1757,7 @@ public int SetNull(int propertyId) public int InsertModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => { if ((uint)index > (uint)Processes.Count) return unchecked((int)0x80070057); Processes.Insert(index, new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(model, _dispatch, _post)); return 0; }), 2 => model is null ? unchecked((int)0x80070057) : Apply(() => { if ((uint)index > (uint)CpuCores.Count) return unchecked((int)0x80070057); CpuCores.Insert(index, new global::NeoHtop.Presentation.Generated.CpuCoreViewModelAdapter(model, _dispatch, _post)); return 0; }), _ => unchecked((int)0x80070057), }; @@ -1780,6 +1769,14 @@ public int SetNull(int propertyId) public int ReplaceModel(int collectionId, int index, IAvnRustViewModel? model) => collectionId switch { + 1 => model is null ? unchecked((int)0x80070057) : Apply(() => + { + if ((uint)index >= (uint)Processes.Count) return unchecked((int)0x80070057); + var previous = Processes[index]; + Processes[index] = new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(model, _dispatch, _post); + previous.Dispose(); + return 0; + }), 2 => model is null ? unchecked((int)0x80070057) : Apply(() => { if ((uint)index >= (uint)CpuCores.Count) return unchecked((int)0x80070057); @@ -1793,6 +1790,14 @@ public int SetNull(int propertyId) public int RemoveAt(int collectionId, int index) => collectionId switch { + 1 => Apply(() => + { + if ((uint)index >= (uint)Processes.Count) return unchecked((int)0x80070057); + var item = Processes[index]; + Processes.RemoveAt(index); + item.Dispose(); + return 0; + }), 2 => Apply(() => { if ((uint)index >= (uint)CpuCores.Count) return unchecked((int)0x80070057); @@ -1806,12 +1811,18 @@ public int SetNull(int propertyId) public int MoveItem(int collectionId, int fromIndex, int toIndex) => collectionId switch { + 1 => Apply(() => { if ((uint)fromIndex >= (uint)Processes.Count || (uint)toIndex >= (uint)Processes.Count) return unchecked((int)0x80070057); Processes.Move(fromIndex, toIndex); return 0; }), 2 => Apply(() => { if ((uint)fromIndex >= (uint)CpuCores.Count || (uint)toIndex >= (uint)CpuCores.Count) return unchecked((int)0x80070057); CpuCores.Move(fromIndex, toIndex); return 0; }), _ => unchecked((int)0x80070057), }; public int ClearCollection(int collectionId) => collectionId switch { + 1 => Apply(() => + { + foreach (var item in Processes) item.Dispose(); + Processes.Clear(); + }), 2 => Apply(() => { foreach (var item in CpuCores) item.Dispose(); @@ -1950,62 +1961,6 @@ public int SetNull(int propertyId) _ => unchecked((int)0x80070057), }; - public int MapSetString(int mapId, string? stringKey, long integerKey, string? value) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapSetInteger(int mapId, string? stringKey, long integerKey, long value) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapSetBoolean(int mapId, string? stringKey, long integerKey, int value) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapSetDouble(int mapId, string? stringKey, long integerKey, double value) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapSetModel(int mapId, string? stringKey, long integerKey, IAvnRustViewModel? value) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapRemove(int mapId, string? stringKey, long integerKey) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int MapClear(int mapId) => mapId switch - { - _ => unchecked((int)0x80070057), - }; - - public int SetCommandProgress(int commandId, int hasValue, double value, string? message) => commandId switch - { - _ => unchecked((int)0x80070057), - }; - - public int SetCommandResult(int commandId, IAvnRustViewModel? result) => commandId switch - { - _ => unchecked((int)0x80070057), - }; - - public int SetCommandRunning(int commandId, int running) => commandId switch - { - _ => unchecked((int)0x80070057), - }; - - /// - /// Enqueues one range batch. Like this never reads, - /// applies or completes the batch on the submitting (Rust worker) stack. - /// - public int PublishRange(IAvnRustVmRangeBatch? batch) => _ranges.Publish(batch); - public int ReplaceStringSnapshot(int collectionId, IReadOnlyList values) => collectionId switch { _ => unchecked((int)0x80070057), @@ -2013,6 +1968,15 @@ public int SetNull(int propertyId) public int ReplaceModelSnapshot(int collectionId, IReadOnlyList values) => collectionId switch { + 1 => Apply(() => + { + var staged = new List(); + try { foreach (var value in values) staged.Add(new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(value, _dispatch, _post)); } + catch { foreach (var value in staged) TryDispose(value); throw; } + var previous = Processes.ToArray(); + Processes.ReplaceSnapshot(staged); + foreach (var value in previous) TryDispose(value); + }), 2 => Apply(() => { var staged = new List(); @@ -2131,6 +2095,7 @@ bool IRustVmBatchTarget.TryGetCollection(int collectionId, out RustVmBatchCollec { collection = collectionId switch { + 1 => new RustVmBatchCollectionInfo(nameof(Processes), RustVmValueWireKind.Model, Processes), 2 => new RustVmBatchCollectionInfo(nameof(CpuCores), RustVmValueWireKind.Model, CpuCores), _ => default, }; @@ -2180,6 +2145,7 @@ bool IRustVmBatchTarget.TryGetCommand(int commandId, out IRustVmBatchCommand com IDisposable IRustVmBatchTarget.CreateNestedElement(int collectionId, IAvnRustViewModel model) => collectionId switch { + 1 => new global::NeoHtop.Presentation.Generated.ProcessRowViewModelAdapter(model, _dispatch, _post), 2 => new global::NeoHtop.Presentation.Generated.CpuCoreViewModelAdapter(model, _dispatch, _post), _ => throw new ArgumentOutOfRangeException(nameof(collectionId)), }; @@ -2894,7 +2860,6 @@ private void ApplyThemeVariant() private void DisposeCore() { - _ranges.Close(); try { Check(_model.Detach()); @@ -2905,28 +2870,10 @@ private void DisposeCore() } } - private RustWindowedCollection? ResolveWindow(int collectionId) => collectionId switch - { - 1 => Processes, - _ => null, - }; - - /// - /// Reads each window's dataset identity once, so the first frame already - /// reports the real total count instead of an empty list. Reading it is a - /// lock-free producer-side lookup; it never enters application model code. - /// - private void PrimeWindows(IAvnRustRangeSource? source) - { - if (source is null) return; - if (source.GetRangeState(1, out var generation1, out var total1) >= 0) - Processes.ResetTo(generation1, total1); - } - private void DisposeNestedAdapters() { + foreach (var item in Processes) TryDispose(item); foreach (var item in CpuCores) TryDispose(item); - TryDispose(Processes); } private static void TryDispose(IDisposable? value) diff --git a/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelMetadata.g.cs b/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelMetadata.g.cs index 2d52ca8..d7dfb8f 100644 --- a/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelMetadata.g.cs +++ b/apps/system-monitor/NeoHtop.Presentation/Generated/MainViewModelMetadata.g.cs @@ -108,7 +108,7 @@ public static class MainViewModelMetadata new(93, "StoragePercentLabel", RustViewModelValueKind.String, false, false, "-", null), ], [ - new(1, "Processes", RustViewModelValueKind.Model, global::NeoHtop.Presentation.Generated.ProcessRowViewModelMetadata.Descriptor, CreateProcessesTable(), new(64, 8), null, false), + new(1, "Processes", RustViewModelValueKind.Model, global::NeoHtop.Presentation.Generated.ProcessRowViewModelMetadata.Descriptor, CreateProcessesTable(), null, null, false), new(2, "CpuCores", RustViewModelValueKind.Model, global::NeoHtop.Presentation.Generated.CpuCoreViewModelMetadata.Descriptor, null, null, null, false), ], [ diff --git a/apps/system-monitor/NeoHtop.Presentation/Themes/WinUiFluent.axaml b/apps/system-monitor/NeoHtop.Presentation/Themes/WinUiFluent.axaml index 6971f72..e1fbec1 100644 --- a/apps/system-monitor/NeoHtop.Presentation/Themes/WinUiFluent.axaml +++ b/apps/system-monitor/NeoHtop.Presentation/Themes/WinUiFluent.axaml @@ -47,8 +47,8 @@ 6 + 6,4 6 Segoe UI Variable, Segoe UI Segoe Fluent Icons, Segoe MDL2 Assets - diff --git a/apps/system-monitor/NeoHtop.Presentation/Views/MainWindow.axaml b/apps/system-monitor/NeoHtop.Presentation/Views/MainWindow.axaml index d86afdd..3f10e31 100644 --- a/apps/system-monitor/NeoHtop.Presentation/Views/MainWindow.axaml +++ b/apps/system-monitor/NeoHtop.Presentation/Views/MainWindow.axaml @@ -49,8 +49,8 @@