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:
parent
0788b01ff9
commit
f7b384506b
4 changed files with 297 additions and 2 deletions
|
|
@ -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
180
lib/history_page.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
31
run.sh
Executable 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" "$@"
|
||||
Loading…
Reference in a new issue