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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user