Add package history window and run.sh build/launch script

Shows applied dnf transactions newest-first, flagging the current
state and the original release, each expandable into dnf history
info. Opens as a separate OS process/window via --history rather than
a page in the main app. run.sh builds and runs through a toolbox since
the host's build toolchain (cmake/ninja/gtk3-devel) isn't available
directly on this immutable OS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-06 06:58:25 -08:00
parent 0788b01ff9
commit f7b384506b
4 changed files with 297 additions and 2 deletions

View file

@ -32,6 +32,22 @@ class RepoInfo {
RepoInfo({required this.id, required this.name});
}
class HistoryEntry {
final int id;
final String commandLine;
final String dateTime;
final String action;
final int altered;
HistoryEntry({
required this.id,
required this.commandLine,
required this.dateTime,
required this.action,
required this.altered,
});
}
class DnfService {
/// Lists enabled repos (the "channels" a package can come from).
static Future<List<RepoInfo>> listRepos() async {
@ -193,6 +209,44 @@ class DnfService {
return targets;
}
/// Lists the system's applied-transaction history (`dnf history list`),
/// newest first. Transactions are strictly sequential, so the highest id
/// is always the current state and the lowest id is the original release
/// the system was first installed from.
static Future<List<HistoryEntry>> historyList() async {
final result = await Process.run('dnf', ['history', 'list']);
if (result.exitCode != 0) {
throw Exception('dnf history list failed: ${result.stderr}');
}
final entries = <HistoryEntry>[];
for (final line in (result.stdout as String).split('\n')) {
if (!line.contains('|')) continue; // skips the dashed separator row
final parts = line.split('|').map((p) => p.trim()).toList();
if (parts.length < 5) continue;
final id = int.tryParse(parts[0]);
if (id == null) continue; // skips the header row ("ID | ...")
entries.add(HistoryEntry(
id: id,
commandLine: parts[1],
dateTime: parts[2],
action: parts[3],
altered: int.tryParse(RegExp(r'\d+').firstMatch(parts[4])?.group(0) ?? '') ?? 0,
));
}
entries.sort((a, b) => b.id.compareTo(a.id));
return entries;
}
/// Full detail for one transaction (`dnf history info <id>`), i.e. the
/// individual package changes it applied.
static Future<String> historyInfo(int id) async {
final result = await Process.run('dnf', ['history', 'info', '$id']);
if (result.exitCode != 0) {
throw Exception('dnf history info failed: ${result.stderr}');
}
return result.stdout as String;
}
/// Launches a desktop entry (via gtk-launch) or a raw binary, detached
/// from this process so closing the app doesn't kill the launched one.
static Future<void> launch(LaunchTarget target) async {

180
lib/history_page.dart Normal file
View file

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'dnf_service.dart';
/// Standalone app (run as its own OS process/window, see main.dart's
/// `--history` flag) showing the system's dnf transaction history: every
/// applied change, which one is current, and which was the original
/// release the system was installed from.
class HistoryApp extends StatelessWidget {
const HistoryApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Package History',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const HistoryPage(),
);
}
}
class HistoryPage extends StatefulWidget {
const HistoryPage({super.key});
@override
State<HistoryPage> createState() => _HistoryPageState();
}
class _HistoryPageState extends State<HistoryPage> {
List<HistoryEntry>? _entries;
String? _error;
final Map<int, String> _infoCache = {};
final Set<int> _loadingInfo = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_entries = null;
_error = null;
});
try {
final entries = await DnfService.historyList();
if (mounted) setState(() => _entries = entries);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
Future<void> _loadInfo(int id) async {
if (_infoCache.containsKey(id) || _loadingInfo.contains(id)) return;
setState(() => _loadingInfo.add(id));
try {
final info = await DnfService.historyInfo(id);
if (mounted) setState(() => _infoCache[id] = info);
} catch (e) {
if (mounted) setState(() => _infoCache[id] = 'Failed to load: $e');
} finally {
if (mounted) setState(() => _loadingInfo.remove(id));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Package History'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: _entries == null ? null : _load,
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error, color: Theme.of(context).colorScheme.error, size: 32),
const SizedBox(height: 12),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(onPressed: _load, child: const Text('Retry')),
],
),
),
);
}
final entries = _entries;
if (entries == null) {
return const Center(child: CircularProgressIndicator());
}
if (entries.isEmpty) {
return const Center(child: Text('No transaction history found.'));
}
final currentId = entries.first.id; // newest-first list
final originalId = entries.last.id;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: entries.length,
itemBuilder: (context, i) {
final entry = entries[i];
final isCurrent = entry.id == currentId;
final isOriginal = entry.id == originalId;
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: isCurrent ? Theme.of(context).colorScheme.secondaryContainer : null,
child: ExpansionTile(
onExpansionChanged: (expanded) {
if (expanded) _loadInfo(entry.id);
},
title: Row(
children: [
Text('#${entry.id} ${entry.action}',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 8),
if (isCurrent) const _Badge(label: 'Current', color: Colors.green),
if (isOriginal) const _Badge(label: 'Original release', color: Colors.blueGrey),
],
),
subtitle: Text(
'${entry.dateTime} · ${entry.commandLine.isEmpty ? '(no command line)' : entry.commandLine} · ${entry.altered} altered',
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Align(
alignment: Alignment.centerLeft,
child: _loadingInfo.contains(entry.id)
? const Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
)
: SelectableText(
_infoCache[entry.id] ?? '',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
),
],
),
);
},
);
}
}
class _Badge extends StatelessWidget {
final String label;
final Color color;
const _Badge({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600)),
);
}
}

View file

@ -1,10 +1,20 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'dnf_service.dart';
import 'category_service.dart';
import 'settings_page.dart';
import 'history_page.dart';
void main() {
void main(List<String> args) {
// The history browser runs as its own OS process/window rather than a
// page within this app, launched by re-invoking this same executable
// with --history (see _openHistory below).
if (args.contains('--history')) {
runApp(const HistoryApp());
} else {
runApp(const DnfPackageStoreApp());
}
}
class DnfPackageStoreApp extends StatelessWidget {
@ -81,6 +91,21 @@ class _SearchPageState extends State<SearchPage> {
if (mounted) setState(() => _categoryIndex = index);
}
Future<void> _openHistory() async {
try {
await Process.start(
Platform.resolvedExecutable,
[...Platform.executableArguments, '--history'],
mode: ProcessStartMode.detached,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Failed to open history window: $e')));
}
}
}
Future<void> _openSettings() async {
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
// PackageKit availability may have changed (e.g. uninstalled from Settings).
@ -279,6 +304,11 @@ class _SearchPageState extends State<SearchPage> {
appBar: AppBar(
title: const Text('DNF Package Store'),
actions: [
IconButton(
icon: const Icon(Icons.history),
tooltip: 'Package history',
onPressed: _openHistory,
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',

31
run.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Builds and runs the DNF Package Store Linux desktop app.
#
# The host OS is immutable (Fedora Aurora/Kinoite), so the native build
# toolchain (cmake, ninja, gtk3 headers) that `flutter build linux` needs
# lives in a toolbox instead of on the host. This script creates a default
# toolbox if none exists, installs those packages into it once, then builds
# and runs through it. The toolbox shares $HOME with the host, so the
# Flutter SDK and this project are visible from inside it without copying
# anything.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
FLUTTER=/var/home/ironmagma/Code/.flutter-sdk/bin/flutter
if ! toolbox list -c 2>/dev/null | tail -n +2 | grep -q .; then
echo "No toolbox found — creating the default one..."
toolbox create -y
fi
if ! toolbox run rpm -q cmake ninja-build gtk3-devel >/dev/null 2>&1; then
echo "Installing Linux build dependencies into the toolbox..."
toolbox run sudo dnf install -y cmake ninja-build gtk3-devel
fi
echo "Building..."
toolbox run "$FLUTTER" build linux --debug
BUNDLE="build/linux/x64/debug/bundle"
echo "Running $BUNDLE/dnf_package_store ${*}"
exec toolbox run "$BUNDLE/dnf_package_store" "$@"