add project switcher dropdown in hat bar

Click "clide > project" label to open a dropdown with:
- Search/filter box for recent projects
- Recent projects list with path, branch, and time ago
- Open Local Project action (path dialog)
- New Window (spawns detached process)
- Close Workspace (returns to welcome screen)

Dropdown uses DialogRouter, dismissible via Escape or backdrop
click. Reuses ProjectManager.recents and ClideFilterBox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:34:27 +02:00
co-authored by Claude Opus 4.6
parent a44a2efae7
commit 80b13e8e4b
+366 -13
View File
@@ -1,4 +1,5 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'dart:io' show Platform, Process, ProcessStartMode;
import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/extension/src/contribution.dart';
@@ -234,18 +235,7 @@ class _HatBar extends StatelessWidget {
_LeftHatContent(tokens: tokens, wc: kernel.window),
Expanded(
child: Center(
child: ListenableBuilder(
listenable: kernel.project,
builder: (ctx, _) {
final name = kernel.project.current?.path.split('/').last;
return ClideText(
name != null ? 'clide > $name' : 'clide',
fontSize: 12,
color: tokens.chromeForeground,
fontFamily: clideMonoFamily,
);
},
),
child: _ProjectSwitcherButton(kernel: kernel, tokens: tokens),
),
),
_RightHatContent(tokens: tokens, wc: kernel.window),
@@ -353,6 +343,369 @@ class _WinBtnState extends State<_WinBtn> {
}
}
class _ProjectSwitcherButton extends StatefulWidget {
const _ProjectSwitcherButton({required this.kernel, required this.tokens});
final KernelServices kernel;
final SurfaceTokens tokens;
@override
State<_ProjectSwitcherButton> createState() => _ProjectSwitcherButtonState();
}
class _ProjectSwitcherButtonState extends State<_ProjectSwitcherButton> {
bool _hover = false;
void _openSwitcher() {
widget.kernel.dialog.show<String>((ctx, dismiss) {
return _ProjectSwitcherDropdown(kernel: widget.kernel, onDismiss: dismiss);
});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.kernel.project,
builder: (ctx, _) {
final name = widget.kernel.project.current?.path.split('/').last;
final label = name != null ? 'clide > $name' : 'clide';
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _openSwitcher,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText(label, fontSize: 12, color: _hover ? widget.tokens.globalForeground : widget.tokens.chromeForeground, fontFamily: clideMonoFamily),
const SizedBox(width: 4),
ClideIcon(PhosphorIcons.caretDown, size: 8, color: widget.tokens.chromeForeground),
],
),
),
);
},
);
}
}
class _ProjectSwitcherDropdown extends StatefulWidget {
const _ProjectSwitcherDropdown({required this.kernel, required this.onDismiss});
final KernelServices kernel;
final void Function([String?]) onDismiss;
@override
State<_ProjectSwitcherDropdown> createState() => _ProjectSwitcherDropdownState();
}
class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
String _filter = '';
late final FocusNode _focus;
@override
void initState() {
super.initState();
_focus = FocusNode()..requestFocus();
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
Future<void> _openProject(String path) async {
final ok = await widget.kernel.project.open(path);
if (ok) {
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
widget.onDismiss();
}
}
void _closeWorkspace() {
widget.kernel.project.close();
widget.onDismiss();
}
void _newWindow() {
Process.start(Platform.resolvedExecutable, [], mode: ProcessStartMode.detached);
widget.onDismiss();
}
void _openFolder() {
widget.onDismiss();
widget.kernel.dialog.show<String>((ctx, dismiss) {
return _OpenFolderDialog(
onOpen: (path) async {
final ok = await widget.kernel.project.open(path);
if (ok) {
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
dismiss(path);
}
},
onCancel: () => dismiss(),
);
});
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final recents = widget.kernel.project.recents;
final lf = _filter.toLowerCase();
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: Container(
width: 380,
constraints: const BoxConstraints(maxHeight: 420),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideFilterBox(hint: 'Search projects…', onChanged: (v) => setState(() => _filter = v)),
if (filtered.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText('Recent Projects', fontSize: clideFontCaption, color: tokens.globalTextMuted),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: filtered.length,
itemBuilder: (ctx, i) => _RecentProjectRow(
project: filtered[i],
tokens: tokens,
onTap: () => _openProject(filtered[i].path),
),
),
),
] else
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
Container(
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
child: Column(
children: [
_ActionRow(label: 'Open Local Project', shortcut: 'Ctrl+O', tokens: tokens, onTap: _openFolder),
_ActionRow(label: 'New Window', shortcut: 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
if (widget.kernel.project.isOpen)
_ActionRow(label: 'Close Workspace', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
],
),
),
],
),
),
);
}
}
class _RecentProjectRow extends StatefulWidget {
const _RecentProjectRow({required this.project, required this.tokens, required this.onTap});
final RecentProject project;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
State<_RecentProjectRow> createState() => _RecentProjectRowState();
}
class _RecentProjectRowState extends State<_RecentProjectRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: _hover ? widget.tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
ClideIcon(PhosphorIcons.folder, size: 14, color: widget.tokens.globalTextMuted),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(widget.project.name, fontSize: 14),
if (widget.project.branch != null)
Row(
children: [
ClideText(widget.project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.gitBranch, size: 10, color: widget.tokens.globalTextMuted),
const SizedBox(width: 3),
ClideText(widget.project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
],
)
else
ClideText(widget.project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
],
),
),
ClideText(widget.project.timeAgo, muted: true, fontSize: 11),
],
),
),
),
);
}
}
class _ActionRow extends StatefulWidget {
const _ActionRow({required this.label, this.shortcut, required this.tokens, required this.onTap});
final String label;
final String? shortcut;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
State<_ActionRow> createState() => _ActionRowState();
}
class _ActionRowState extends State<_ActionRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: _hover ? widget.tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(child: ClideText(widget.label, fontSize: 14)),
if (widget.shortcut != null && widget.shortcut!.isNotEmpty)
ClideText(widget.shortcut!, fontSize: 12, color: widget.tokens.globalTextMuted, fontFamily: clideMonoFamily),
],
),
),
),
);
}
}
class _OpenFolderDialog extends StatefulWidget {
const _OpenFolderDialog({required this.onOpen, required this.onCancel});
final Future<void> Function(String path) onOpen;
final VoidCallback onCancel;
@override
State<_OpenFolderDialog> createState() => _OpenFolderDialogState();
}
class _OpenFolderDialogState extends State<_OpenFolderDialog> {
final _controller = TextEditingController();
final _focus = FocusNode();
String? _error;
bool _loading = false;
@override
void initState() {
super.initState();
_focus.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _submit() async {
final path = _controller.text.trim();
if (path.isEmpty) return;
setState(() { _loading = true; _error = null; });
try {
await widget.onOpen(path);
} catch (_) {
if (mounted) setState(() => _error = 'Not a git repository');
}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 4),
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: _controller,
focusNode: _focus,
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: 12),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
const SizedBox(width: 8),
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
],
),
],
),
);
}
}
class SlotHost extends StatelessWidget {
const SlotHost({super.key, required this.slot});
final SlotId slot;