picker-first startup with per-project sticky override (T-115)

Boot used to auto-open app.lastProject and fall back to the CWD; new
default is the welcome screen as the project picker. Sticky-open is
opt-in: a checkbox on each recent-projects row toggles a
startupSticky flag, and clide auto-opens iff exactly one row has it.
Two-or-more, or none, ⇒ picker (unambiguous user intent).

RecentProject gains the boolean (persisted in app.recentProjects);
ProjectManager exposes stickyProjectPath, openStickyOrNothing,
setStickyStartup, isStickyStartup, and preserves the flag across
reopens.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:26:12 +02:00
co-authored by Claude
parent 980f60f798
commit 7046bf9c70
7 changed files with 161 additions and 9 deletions
+72 -3
View File
@@ -9,20 +9,46 @@ import 'package:clide/kernel/src/toolchain.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened});
const RecentProject({
required this.path,
required this.name,
this.branch,
required this.lastOpened,
this.startupSticky = false,
});
final String path;
final String name;
final String? branch;
final DateTime lastOpened;
Map<String, dynamic> toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()};
/// When set, this project is preferred on startup over the picker.
/// If exactly one recent has this flag, [ProjectManager.openStickyOrNothing]
/// opens it directly; otherwise the welcome screen takes over (T-115).
final bool startupSticky;
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
path: path,
name: name,
branch: branch ?? this.branch,
lastOpened: lastOpened ?? this.lastOpened,
startupSticky: startupSticky ?? this.startupSticky,
);
Map<String, dynamic> toJson() => {
'path': path,
'name': name,
'branch': branch,
'lastOpened': lastOpened.toIso8601String(),
if (startupSticky) 'startupSticky': true,
};
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
path: json['path'] as String? ?? '',
name: json['name'] as String? ?? '',
branch: json['branch'] as String?,
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
startupSticky: json['startupSticky'] as bool? ?? false,
);
String get relativePath {
@@ -104,8 +130,15 @@ class ProjectManager extends ChangeNotifier {
final branch = await _currentBranch(root);
final name = root.split('/').last;
// Preserve the sticky-startup flag across reopens — otherwise the
// user would have to re-toggle it every time they touched the
// project (T-115).
final wasSticky = isStickyStartup(root);
_recents.removeWhere((r) => r.path == root);
_recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now()));
_recents.insert(
0,
RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now(), startupSticky: wasSticky),
);
if (_recents.length > 10) _recents = _recents.sublist(0, 10);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
@@ -122,6 +155,42 @@ class ProjectManager extends ChangeNotifier {
return open(last);
}
/// Path of the lone sticky-startup project, or null if zero or
/// multiple recents have the flag. Zero ⇒ show the picker; multiple
/// ⇒ ambiguous, also show the picker (T-115).
String? get stickyProjectPath {
final sticky = _recents.where((r) => r.startupSticky).toList();
return sticky.length == 1 ? sticky.first.path : null;
}
/// Open the single sticky-startup project, if any. Returns false on
/// "no unambiguous sticky" so the caller (main.dart) can fall through
/// to the welcome screen.
Future<bool> openStickyOrNothing() async {
final path = stickyProjectPath;
if (path == null) return false;
final dir = Directory(path);
if (!await dir.exists()) return false;
return open(path);
}
/// Toggle the per-project sticky-startup flag and persist. No-op if
/// the path isn't in recents (open the project first to land it
/// there).
Future<void> setStickyStartup(String path, bool value) async {
final idx = _recents.indexWhere((r) => r.path == path);
if (idx < 0) return;
if (_recents[idx].startupSticky == value) return;
_recents[idx] = _recents[idx].copyWith(startupSticky: value);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
notifyListeners();
}
bool isStickyStartup(String path) {
final idx = _recents.indexWhere((r) => r.path == path);
return idx >= 0 && _recents[idx].startupSticky;
}
Future<void> close() async {
if (_current == null) return;
_current = null;