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
+51 -2
View File
@@ -264,7 +264,13 @@ class _RecentColumn extends StatelessWidget {
if (recents.isEmpty)
const ClideText('No recent projects.', muted: true, fontSize: 14)
else
for (final r in recents) _RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
for (final r in recents)
_RecentRow(
project: r,
tokens: tokens,
onTap: () => _openRecent(r.path),
onToggleSticky: () => kernel.project.setStickyStartup(r.path, !r.startupSticky),
),
],
);
},
@@ -279,10 +285,16 @@ class _RecentColumn extends StatelessWidget {
}
class _RecentRow extends StatelessWidget {
const _RecentRow({required this.project, required this.tokens, required this.onTap});
const _RecentRow({
required this.project,
required this.tokens,
required this.onTap,
required this.onToggleSticky,
});
final RecentProject project;
final SurfaceTokens tokens;
final VoidCallback onTap;
final VoidCallback onToggleSticky;
@override
Widget build(BuildContext context) {
@@ -318,6 +330,8 @@ class _RecentRow extends StatelessWidget {
],
),
),
_StickyToggle(sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
const SizedBox(width: 12),
ClideText(project.timeAgo, muted: true, fontSize: 13),
],
),
@@ -326,6 +340,41 @@ class _RecentRow extends StatelessWidget {
}
}
/// Sticky-startup checkbox shown on each recent-project row (T-115).
/// When exactly one row is checked, clide opens that project on next
/// launch instead of showing the picker. Tooltip explains the rule.
class _StickyToggle extends StatelessWidget {
const _StickyToggle({required this.sticky, required this.tokens, required this.onTap});
final bool sticky;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ClideTooltip(
message: sticky ? 'Always open this project on launch (uncheck to restore picker)' : 'Always open this project on launch',
child: Semantics(
button: true,
checked: sticky,
label: 'always open this project on launch',
child: ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: sticky ? tokens.statusBarItemActiveBackground : null,
border: Border.all(color: hovered || sticky ? tokens.panelActiveBorder : tokens.globalBorder),
borderRadius: BorderRadius.circular(3),
),
child: sticky ? ClideIcon(PhosphorIcons.check, size: 12, color: tokens.buttonForeground) : null,
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
const _StatusLine({required this.tokens, required this.kernel});
final SurfaceTokens tokens;
+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;
+4 -4
View File
@@ -156,10 +156,10 @@ Future<void> main() async {
if (!kIsWeb) {
await services.project.loadRecents();
var opened = await services.project.openLast();
if (!opened) {
opened = await services.project.open(Directory.current.path);
}
// T-115: picker-first. Auto-open only when exactly one recent has
// its sticky-startup flag set; otherwise the welcome tab (default
// workspace content) serves as the project picker.
final opened = await services.project.openStickyOrNothing();
if (opened) {
services.panels.activateTab(Slots.workspace, 'claude.primary');
}