Eleven leaf sites rendered monospace text from top-level/static helpers that take no BuildContext, so they hard-coded clideMonoFamily and ignored the Settings → Appearance Monospace choice that the D-101 facade made live everywhere else. Thread the resolved family in from the nearest context-bearing caller: - claude tool bodies/results: a required `mono` field on _ConversationTurn + a `mono` arg on the shared toolInputBody chain; - markdown inline `code`/record/file-ref spans: carried on ClideMarkdownHooks, which build() already constructs from context and threads to every static; - search preview styles and welcome tips: a `mono` parameter on the helpers. No behaviour change when the setting is default; these surfaces now switch live with the rest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
585 lines
21 KiB
Dart
585 lines
21 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion;
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:clide/widgets/widgets.dart';
|
|
import 'package:flutter/services.dart' show MissingPluginException;
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
class WelcomeView extends StatelessWidget {
|
|
const WelcomeView({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final kernel = ClideKernel.of(context);
|
|
final tokens = ClideTheme.of(context).surface;
|
|
return LayoutBuilder(
|
|
builder: (context, c) {
|
|
// Tips card sits below the START/RECENT row when there's room
|
|
// for it; on shorter viewports the two centered columns win
|
|
// and the tips drop out cleanly.
|
|
final showTips = c.maxHeight > 640;
|
|
return Stack(
|
|
children: [
|
|
// Scrollable so a short/narrow viewport (many recents, small window)
|
|
// never overflows; minHeight keeps the content vertically centred
|
|
// when there is room (T-273 follow-up — welcome RenderFlex overflow).
|
|
Positioned.fill(
|
|
child: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: c.maxHeight),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 850),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_Header(tokens: tokens),
|
|
const SizedBox(height: 56),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: _StartColumn(tokens: tokens, kernel: kernel),
|
|
),
|
|
const SizedBox(width: 56),
|
|
Expanded(
|
|
child: _RecentColumn(tokens: tokens, kernel: kernel),
|
|
),
|
|
],
|
|
),
|
|
if (showTips) ...[const SizedBox(height: 48), _TipsCard(tokens: tokens)],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 64,
|
|
right: 64,
|
|
bottom: 24,
|
|
child: _StatusLine(tokens: tokens, kernel: kernel),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TipsCard extends StatelessWidget {
|
|
const _TipsCard({required this.tokens});
|
|
final SurfaceTokens tokens;
|
|
|
|
// Every tip mirrors a binding that actually exists in the default
|
|
// preset / contributed commands (T-383) — ctrl-based on the shipped
|
|
// default keymap, hence ⌃ glyphs. If a binding moves, move the tip.
|
|
static const _tips = <(String, String)>[
|
|
('Quick open', '⌃P'),
|
|
('Command palette', '⌃⇧P'),
|
|
('Toggle sidebar', '⌃⇧1'),
|
|
('Toggle context', '⌃⇧3'),
|
|
('Find in files', '⌃⇧F'),
|
|
('Focus mode', '⌃.'),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Split tips into two rows of three so the card lays out as a
|
|
// 3-column grid matching the START/RECENT proportions above.
|
|
final firstRow = _tips.sublist(0, 3);
|
|
final secondRow = _tips.sublist(3);
|
|
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
|
decoration: BoxDecoration(
|
|
color: tokens.panelBackground,
|
|
border: Border.all(color: tokens.panelBorder),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClideText('TIPS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
const SizedBox(height: 14),
|
|
_tipRow(firstRow, ClideSettings.fonts.monoOf(context)),
|
|
const SizedBox(height: 8),
|
|
_tipRow(secondRow, ClideSettings.fonts.monoOf(context)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tipRow(List<(String, String)> tips, String mono) {
|
|
return Row(
|
|
children: [
|
|
for (var i = 0; i < tips.length; i++) ...[
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
|
),
|
|
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: mono),
|
|
],
|
|
),
|
|
),
|
|
if (i < tips.length - 1) const SizedBox(width: 24),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Header extends StatelessWidget {
|
|
const _Header({required this.tokens});
|
|
final SurfaceTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const ClideSvgView.asset('assets/logo/logo.svg', width: 144, height: 144),
|
|
const SizedBox(width: 24),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClideText(clideName, fontSize: clideFontWelcomeBanner, fontWeight: FontWeight.w300, color: tokens.globalForeground),
|
|
ClideText(clideTagline, muted: true, fontSize: clideFontDialogTitle),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartColumn extends StatelessWidget {
|
|
const _StartColumn({required this.tokens, required this.kernel});
|
|
final SurfaceTokens tokens;
|
|
final KernelServices kernel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
const SizedBox(height: 20),
|
|
// Only flows that exist get a tile — the old Clone-from-git and
|
|
// Start-a-Claude-session rows were inert and advertised shortcuts
|
|
// that were never registered (T-383). Re-add each WITH its flow.
|
|
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌃O', tokens: tokens, onTap: () => _openFolder(context)),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openFolder(BuildContext context) async {
|
|
try {
|
|
final picked = await kernel.window.pickDirectory();
|
|
if (picked != null) {
|
|
final ok = await kernel.project.open(picked);
|
|
if (ok) {
|
|
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
|
} else {
|
|
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
|
|
}
|
|
}
|
|
return;
|
|
} on MissingPluginException {
|
|
// Platform has no native picker — fall through to text dialog.
|
|
}
|
|
|
|
kernel.dialog.show<String>((ctx, dismiss) {
|
|
return _OpenProjectDialog(
|
|
onOpen: (path) async {
|
|
final ok = await kernel.project.open(path);
|
|
if (ok) {
|
|
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
|
dismiss(path);
|
|
}
|
|
},
|
|
onCancel: () => dismiss(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
class _ActionRow extends StatelessWidget {
|
|
const _ActionRow({required this.icon, required this.label, this.shortcut, required this.tokens, required this.onTap});
|
|
final ClideIconPainter icon;
|
|
final String label;
|
|
final String? shortcut;
|
|
final SurfaceTokens tokens;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ClideTappable(
|
|
onTap: onTap,
|
|
builder: (context, hovered, _) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
|
child: Row(
|
|
children: [
|
|
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground),
|
|
),
|
|
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecentColumn extends StatelessWidget {
|
|
const _RecentColumn({required this.tokens, required this.kernel});
|
|
final SurfaceTokens tokens;
|
|
final KernelServices kernel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: kernel.project,
|
|
builder: (ctx, _) {
|
|
final recents = kernel.project.recents;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClideText('RECENT', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
const SizedBox(height: 20),
|
|
if (recents.isEmpty)
|
|
const ClideText('No recent projects.', muted: true, fontSize: clideFontCaption)
|
|
else
|
|
for (final r in recents)
|
|
_RecentRow(
|
|
project: r,
|
|
tokens: tokens,
|
|
onTap: () => _openRecent(r.path),
|
|
onToggleSticky: () => kernel.project.setStickyStartup(r.path, !r.startupSticky),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _openRecent(String path) {
|
|
kernel.project.open(path).then((ok) {
|
|
if (ok) kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
|
});
|
|
}
|
|
}
|
|
|
|
class _RecentRow extends StatelessWidget {
|
|
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) {
|
|
return ClideTappable(
|
|
onTap: onTap,
|
|
builder: (context, hovered, _) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ClideText(project.name, fontSize: clideFontBody, fontWeight: FontWeight.w500),
|
|
const SizedBox(height: 3),
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: ClideText(
|
|
project.relativePath,
|
|
muted: true,
|
|
fontSize: clideFontMeta,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (project.branch != null) ...[
|
|
ClideText(' · ', muted: true, fontSize: clideFontMeta),
|
|
ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted),
|
|
const SizedBox(width: 3),
|
|
Flexible(
|
|
child: ClideText(
|
|
project.branch!,
|
|
muted: true,
|
|
fontSize: clideFontMeta,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_StickyToggle(key: ValueKey('welcome.sticky.${project.path}'), sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
|
|
const SizedBox(width: 12),
|
|
ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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({super.key, required this.sticky, required this.tokens, required this.onTap});
|
|
final bool sticky;
|
|
final SurfaceTokens tokens;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
checked: sticky,
|
|
label: 'always open this project on launch',
|
|
tooltip: sticky ? 'Always open this project on launch (uncheck to restore picker)' : '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.byName('check'), size: 12, color: tokens.buttonForeground) : null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusLine extends StatelessWidget {
|
|
const _StatusLine({required this.tokens, required this.kernel});
|
|
final SurfaceTokens tokens;
|
|
final KernelServices kernel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final themeName = kernel.theme.currentName;
|
|
return ListenableBuilder(
|
|
listenable: kernel.toolchain,
|
|
builder: (ctx, _) {
|
|
final tc = kernel.toolchain;
|
|
// FittedBox+scaleDown shrinks the row uniformly on narrow
|
|
// viewports rather than overflowing — at standard widths it's
|
|
// a no-op. The status line is decorative chrome; keep it on
|
|
// one line by accepting a tiny font on very narrow screens.
|
|
return FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerRight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ClideText('$clideName $clideVersion', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
|
if (!tc.resolved)
|
|
ClideText('checking…', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context))
|
|
else if (tc.allOk)
|
|
ClideText('application ok', fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusSuccess)
|
|
else
|
|
ClideText(
|
|
tc.missing.map((t) => '$t not found').join(' · '),
|
|
fontSize: clideFontSmall,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
color: tokens.statusWarning,
|
|
),
|
|
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
|
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemeLink extends StatelessWidget {
|
|
const _ThemeLink({required this.tokens, required this.kernel, required this.themeName});
|
|
final SurfaceTokens tokens;
|
|
final KernelServices kernel;
|
|
final String themeName;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ClideTappable(
|
|
onTap: () => kernel.commands.execute('theme.pick'),
|
|
builder: (ctx, hovered, _) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ClideText('theme: ', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
ClideText(
|
|
themeName,
|
|
fontSize: clideFontSmall,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
color: hovered ? tokens.globalForeground : tokens.globalFocus,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OpenProjectDialog extends StatefulWidget {
|
|
const _OpenProjectDialog({required this.onOpen, required this.onCancel});
|
|
final Future<void> Function(String path) onOpen;
|
|
final VoidCallback onCancel;
|
|
|
|
@override
|
|
State<_OpenProjectDialog> createState() => _OpenProjectDialogState();
|
|
}
|
|
|
|
class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
|
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: clideFontDialogTitle, fontWeight: FontWeight.w600),
|
|
const SizedBox(height: 4),
|
|
const ClideText('Enter the path to a git repository.', muted: true, fontSize: clideFontMeta),
|
|
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: clideFontCaption,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
fontFamilyFallback: clideMonoFamilyFallback,
|
|
),
|
|
cursorColor: tokens.globalForeground,
|
|
backgroundCursorColor: tokens.globalTextMuted,
|
|
onSubmitted: (_) => unawaited(_submit()),
|
|
),
|
|
),
|
|
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
|
|
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 _NotARepoDialog extends StatelessWidget {
|
|
const _NotARepoDialog({required this.path, required this.onDismiss});
|
|
final String path;
|
|
final VoidCallback onDismiss;
|
|
|
|
@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('No git repo found', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
|
|
const SizedBox(height: 8),
|
|
ClideText(path, muted: true, fontSize: clideFontMeta),
|
|
const SizedBox(height: 8),
|
|
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: clideFontMeta),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|