898 lines
30 KiB
Dart
898 lines
30 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_html/flutter_html.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:markdown/markdown.dart' as md;
|
|
|
|
import '../i18n/app_language.dart';
|
|
import '../runtime/assistant_artifacts.dart';
|
|
import '../runtime/runtime_models.dart';
|
|
import '../theme/app_palette.dart';
|
|
import '../theme/app_theme.dart';
|
|
import 'section_tabs.dart';
|
|
import 'surface_card.dart';
|
|
|
|
typedef AssistantArtifactSnapshotLoader =
|
|
Future<AssistantArtifactSnapshot> Function();
|
|
typedef AssistantArtifactPreviewLoader =
|
|
Future<AssistantArtifactPreview> Function(AssistantArtifactEntry entry);
|
|
typedef AssistantArtifactOpenWorkspace = Future<void> Function();
|
|
|
|
enum AssistantArtifactSidebarTab { files, preview }
|
|
|
|
class AssistantArtifactSidebar extends StatefulWidget {
|
|
const AssistantArtifactSidebar({
|
|
super.key,
|
|
required this.sessionKey,
|
|
required this.threadTitle,
|
|
required this.workspacePath,
|
|
required this.workspaceKind,
|
|
required this.onCollapse,
|
|
required this.loadSnapshot,
|
|
required this.loadPreview,
|
|
this.onOpenWorkspace,
|
|
});
|
|
|
|
final String sessionKey;
|
|
final String threadTitle;
|
|
final String workspacePath;
|
|
final WorkspaceRefKind workspaceKind;
|
|
final VoidCallback onCollapse;
|
|
final AssistantArtifactSnapshotLoader loadSnapshot;
|
|
final AssistantArtifactPreviewLoader loadPreview;
|
|
final AssistantArtifactOpenWorkspace? onOpenWorkspace;
|
|
|
|
@override
|
|
State<AssistantArtifactSidebar> createState() =>
|
|
_AssistantArtifactSidebarState();
|
|
}
|
|
|
|
class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
|
|
AssistantArtifactSidebarTab _activeTab = AssistantArtifactSidebarTab.files;
|
|
AssistantArtifactSnapshot? _snapshot;
|
|
AssistantArtifactEntry? _selectedEntry;
|
|
AssistantArtifactPreview _preview = const AssistantArtifactPreview.empty();
|
|
Object? _loadError;
|
|
bool _loadingSnapshot = false;
|
|
bool _loadingPreview = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_refreshSnapshot());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant AssistantArtifactSidebar oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.sessionKey != widget.sessionKey ||
|
|
oldWidget.workspacePath != widget.workspacePath ||
|
|
oldWidget.workspaceKind != widget.workspaceKind) {
|
|
_activeTab = AssistantArtifactSidebarTab.files;
|
|
_selectedEntry = null;
|
|
_preview = const AssistantArtifactPreview.empty();
|
|
unawaited(_refreshSnapshot());
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
final theme = Theme.of(context);
|
|
final snapshot = _snapshot;
|
|
final entriesForPreview = _previewCandidates(snapshot);
|
|
final selectedEntry = _selectedEntry;
|
|
final workspacePath = widget.workspacePath.trim();
|
|
final canCopyWorkspace = workspacePath.isNotEmpty;
|
|
final canOpenWorkspace =
|
|
canCopyWorkspace &&
|
|
widget.workspaceKind == WorkspaceRefKind.localPath &&
|
|
widget.onOpenWorkspace != null;
|
|
|
|
return SurfaceCard(
|
|
key: const Key('assistant-artifact-pane'),
|
|
tone: SurfaceCardTone.chrome,
|
|
padding: EdgeInsets.zero,
|
|
borderRadius: AppRadius.sidebar,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.md,
|
|
AppSpacing.md,
|
|
AppSpacing.sm,
|
|
AppSpacing.sm,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.threadTitle.trim().isEmpty
|
|
? appText('当前线程', 'Current thread')
|
|
: widget.threadTitle.trim(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxs),
|
|
Text(
|
|
appText('当前任务工作路径', 'Current task workspace'),
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: palette.textMuted,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxs),
|
|
Tooltip(
|
|
message: workspacePath,
|
|
child: GestureDetector(
|
|
key: const Key(
|
|
'assistant-artifact-pane-workspace-ref-container',
|
|
),
|
|
onDoubleTap: canOpenWorkspace
|
|
? () => unawaited(_openWorkspace())
|
|
: null,
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.xs,
|
|
vertical: AppSpacing.xxs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: palette.chromeSurface.withValues(
|
|
alpha: 0.72,
|
|
),
|
|
borderRadius: BorderRadius.circular(
|
|
AppRadius.button,
|
|
),
|
|
border: Border.all(color: palette.chromeStroke),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 1),
|
|
child: Icon(
|
|
widget.workspaceKind ==
|
|
WorkspaceRefKind.localPath
|
|
? Icons.folder_open_rounded
|
|
: Icons.cloud_queue_rounded,
|
|
size: 14,
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.xxs),
|
|
Expanded(
|
|
child: Text(
|
|
_workspaceSummary(
|
|
widget.workspacePath,
|
|
widget.workspaceKind,
|
|
),
|
|
key: const Key(
|
|
'assistant-artifact-pane-workspace-ref',
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: palette.textSecondary,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.xxs),
|
|
IconButton(
|
|
key: const Key(
|
|
'assistant-artifact-pane-copy-workspace-ref',
|
|
),
|
|
tooltip: appText(
|
|
'复制工作路径',
|
|
'Copy workspace path',
|
|
),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 24,
|
|
minHeight: 24,
|
|
),
|
|
onPressed: canCopyWorkspace
|
|
? _copyWorkspace
|
|
: null,
|
|
icon: const Icon(
|
|
Icons.content_copy_rounded,
|
|
size: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
key: const Key('assistant-artifact-pane-refresh'),
|
|
tooltip: appText('刷新产物', 'Refresh artifacts'),
|
|
style: IconButton.styleFrom(
|
|
minimumSize: const Size(40, 40),
|
|
maximumSize: const Size(40, 40),
|
|
),
|
|
onPressed: _loadingSnapshot ? null : _refreshSnapshot,
|
|
icon: _loadingSnapshot
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh_rounded, size: 18),
|
|
),
|
|
IconButton(
|
|
key: const Key('assistant-artifact-pane-collapse'),
|
|
tooltip: appText('收起右侧栏', 'Collapse sidebar'),
|
|
style: IconButton.styleFrom(
|
|
minimumSize: const Size(40, 40),
|
|
maximumSize: const Size(40, 40),
|
|
),
|
|
onPressed: widget.onCollapse,
|
|
icon: const Icon(Icons.keyboard_double_arrow_right_rounded),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: SectionTabs(
|
|
size: SectionTabsSize.small,
|
|
items: AssistantArtifactSidebarTab.values
|
|
.map(_labelForTab)
|
|
.toList(growable: false),
|
|
value: _labelForTab(_activeTab),
|
|
onChanged: (value) {
|
|
final nextTab = AssistantArtifactSidebarTab.values.firstWhere(
|
|
(item) => _labelForTab(item) == value,
|
|
orElse: () => AssistantArtifactSidebarTab.files,
|
|
);
|
|
setState(() {
|
|
_activeTab = nextTab;
|
|
});
|
|
if (nextTab == AssistantArtifactSidebarTab.preview &&
|
|
selectedEntry == null &&
|
|
entriesForPreview.isNotEmpty) {
|
|
unawaited(_selectEntry(entriesForPreview.first));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 160),
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
child: _buildTabBody(
|
|
context,
|
|
snapshot: snapshot,
|
|
previewCandidates: entriesForPreview,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _copyWorkspace() async {
|
|
final workspacePath = widget.workspacePath.trim();
|
|
if (workspacePath.isEmpty) {
|
|
return;
|
|
}
|
|
await Clipboard.setData(ClipboardData(text: workspacePath));
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(appText('工作路径已复制', 'Workspace path copied')),
|
|
duration: const Duration(milliseconds: 1200),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openWorkspace() async {
|
|
if (widget.onOpenWorkspace == null) {
|
|
return;
|
|
}
|
|
await widget.onOpenWorkspace!.call();
|
|
}
|
|
|
|
Widget _buildTabBody(
|
|
BuildContext context, {
|
|
required AssistantArtifactSnapshot? snapshot,
|
|
required List<AssistantArtifactEntry> previewCandidates,
|
|
}) {
|
|
if (_loadError != null) {
|
|
return _SidebarEmptyState(
|
|
key: const Key('assistant-artifact-pane-empty'),
|
|
icon: Icons.error_outline_rounded,
|
|
title: appText('产物载入失败', 'Artifacts failed to load'),
|
|
message: _loadError.toString(),
|
|
);
|
|
}
|
|
if (snapshot == null && _loadingSnapshot) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot == null) {
|
|
return _SidebarEmptyState(
|
|
key: const Key('assistant-artifact-pane-empty'),
|
|
icon: Icons.inbox_outlined,
|
|
title: appText('暂无产物', 'No artifacts yet'),
|
|
message: appText(
|
|
'展开右侧栏后会按需加载当前线程的工作目录内容。',
|
|
'Open the sidebar to load the current thread workspace on demand.',
|
|
),
|
|
);
|
|
}
|
|
return switch (_activeTab) {
|
|
AssistantArtifactSidebarTab.files => _ArtifactEntryList(
|
|
key: const Key('assistant-artifact-tab-files'),
|
|
entries: previewCandidates,
|
|
emptyMessage: _filesEmptyMessage(snapshot),
|
|
onSelectEntry: _selectEntry,
|
|
selectedEntry: _selectedEntry,
|
|
),
|
|
AssistantArtifactSidebarTab.preview => _ArtifactPreviewPanel(
|
|
key: const Key('assistant-artifact-tab-preview'),
|
|
entry: _selectedEntry,
|
|
preview: _preview,
|
|
loading: _loadingPreview,
|
|
fallbackEntries: previewCandidates,
|
|
onSelectEntry: _selectEntry,
|
|
),
|
|
};
|
|
}
|
|
|
|
List<AssistantArtifactEntry> _previewCandidates(
|
|
AssistantArtifactSnapshot? snapshot,
|
|
) {
|
|
if (snapshot == null) {
|
|
return const <AssistantArtifactEntry>[];
|
|
}
|
|
final seen = <String>{};
|
|
final merged = <AssistantArtifactEntry>[
|
|
...snapshot.resultEntries,
|
|
...snapshot.fileEntries,
|
|
];
|
|
return merged
|
|
.where((item) => seen.add(item.relativePath))
|
|
.toList(growable: false);
|
|
}
|
|
|
|
String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) {
|
|
final filesMessage = snapshot.filesMessage.trim();
|
|
if (filesMessage.isNotEmpty) {
|
|
return filesMessage;
|
|
}
|
|
final resultsMessage = snapshot.resultMessage.trim();
|
|
if (resultsMessage.isNotEmpty) {
|
|
return resultsMessage;
|
|
}
|
|
return appText(
|
|
'当前线程里还没有可展示的文件。',
|
|
'No files are available for this thread yet.',
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshSnapshot() async {
|
|
setState(() {
|
|
_loadingSnapshot = true;
|
|
_loadError = null;
|
|
});
|
|
try {
|
|
final snapshot = await widget.loadSnapshot();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final nextSelected = _reconcileSelection(
|
|
snapshot,
|
|
previous: _selectedEntry,
|
|
);
|
|
setState(() {
|
|
_snapshot = snapshot;
|
|
_selectedEntry = nextSelected;
|
|
_loadingSnapshot = false;
|
|
});
|
|
if (_activeTab == AssistantArtifactSidebarTab.preview &&
|
|
nextSelected != null) {
|
|
await _loadPreview(nextSelected);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_loadingSnapshot = false;
|
|
_loadError = error;
|
|
});
|
|
}
|
|
}
|
|
|
|
AssistantArtifactEntry? _reconcileSelection(
|
|
AssistantArtifactSnapshot snapshot, {
|
|
AssistantArtifactEntry? previous,
|
|
}) {
|
|
final candidates = _previewCandidates(snapshot);
|
|
if (previous == null) {
|
|
return candidates.isEmpty ? null : candidates.first;
|
|
}
|
|
for (final item in candidates) {
|
|
if (item.relativePath == previous.relativePath) {
|
|
return item;
|
|
}
|
|
}
|
|
return candidates.isEmpty ? null : candidates.first;
|
|
}
|
|
|
|
Future<void> _selectEntry(AssistantArtifactEntry entry) async {
|
|
setState(() {
|
|
_selectedEntry = entry;
|
|
_activeTab = AssistantArtifactSidebarTab.preview;
|
|
});
|
|
await _loadPreview(entry);
|
|
}
|
|
|
|
Future<void> _loadPreview(AssistantArtifactEntry entry) async {
|
|
setState(() {
|
|
_loadingPreview = true;
|
|
_preview = const AssistantArtifactPreview.empty();
|
|
});
|
|
try {
|
|
final preview = await widget.loadPreview(entry);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_preview = preview;
|
|
_loadingPreview = false;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_preview = AssistantArtifactPreview.empty(message: error.toString());
|
|
_loadingPreview = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _labelForTab(AssistantArtifactSidebarTab tab) {
|
|
return switch (tab) {
|
|
AssistantArtifactSidebarTab.files => appText('全部文件', 'All files'),
|
|
AssistantArtifactSidebarTab.preview => appText('预览', 'Preview'),
|
|
};
|
|
}
|
|
|
|
static String _workspaceSummary(String workspacePath, WorkspaceRefKind kind) {
|
|
final trimmed = workspacePath.trim();
|
|
if (trimmed.isEmpty) {
|
|
return appText('未设置', 'Not set');
|
|
}
|
|
if (kind == WorkspaceRefKind.remotePath) {
|
|
return trimmed;
|
|
}
|
|
final normalized = trimmed.replaceAll('\\', '/');
|
|
if (normalized.length <= 56) {
|
|
return normalized;
|
|
}
|
|
final segments = normalized
|
|
.split('/')
|
|
.where((item) => item.isNotEmpty)
|
|
.toList();
|
|
if (segments.length <= 4) {
|
|
return normalized;
|
|
}
|
|
return '.../${segments.sublist(segments.length - 4).join('/')}';
|
|
}
|
|
}
|
|
|
|
class AssistantArtifactSidebarRevealButton extends StatelessWidget {
|
|
const AssistantArtifactSidebarRevealButton({super.key, required this.onTap});
|
|
|
|
static const double _buttonSize = 28;
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
return Tooltip(
|
|
message: appText('展开右侧栏', 'Expand sidebar'),
|
|
child: IconButton(
|
|
key: const Key('assistant-artifact-pane-toggle'),
|
|
onPressed: onTap,
|
|
visualDensity: VisualDensity.compact,
|
|
splashRadius: 18,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: _buttonSize,
|
|
height: _buttonSize,
|
|
),
|
|
style: IconButton.styleFrom(
|
|
padding: EdgeInsets.zero,
|
|
minimumSize: const Size(_buttonSize, _buttonSize),
|
|
maximumSize: const Size(_buttonSize, _buttonSize),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: palette.textSecondary,
|
|
overlayColor: palette.chromeSurfacePressed,
|
|
side: BorderSide.none,
|
|
shape: const CircleBorder(),
|
|
),
|
|
icon: const Icon(
|
|
Icons.keyboard_double_arrow_left_rounded,
|
|
size: 20,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ArtifactEntryList extends StatelessWidget {
|
|
const _ArtifactEntryList({
|
|
super.key,
|
|
required this.entries,
|
|
required this.emptyMessage,
|
|
required this.onSelectEntry,
|
|
required this.selectedEntry,
|
|
});
|
|
|
|
final List<AssistantArtifactEntry> entries;
|
|
final String emptyMessage;
|
|
final ValueChanged<AssistantArtifactEntry> onSelectEntry;
|
|
final AssistantArtifactEntry? selectedEntry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (entries.isEmpty) {
|
|
return _SidebarEmptyState(
|
|
key: const Key('assistant-artifact-pane-empty'),
|
|
icon: Icons.folder_open_outlined,
|
|
title: appText('暂无文件', 'No files'),
|
|
message: emptyMessage,
|
|
);
|
|
}
|
|
final palette = context.palette;
|
|
final theme = Theme.of(context);
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.md,
|
|
0,
|
|
AppSpacing.md,
|
|
AppSpacing.md,
|
|
),
|
|
itemCount: entries.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.xs),
|
|
itemBuilder: (context, index) {
|
|
final entry = entries[index];
|
|
final selected =
|
|
selectedEntry?.relativePath == entry.relativePath &&
|
|
selectedEntry?.workspacePath == entry.workspacePath;
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
key: ValueKey<String>(
|
|
'assistant-artifact-entry-${entry.relativePath}',
|
|
),
|
|
onTap: () => onSelectEntry(entry),
|
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? palette.accentMuted.withValues(alpha: 0.88)
|
|
: palette.chromeSurface.withValues(alpha: 0.72),
|
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
|
border: Border.all(
|
|
color: selected ? palette.accent : palette.chromeStroke,
|
|
),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
_iconForEntry(entry),
|
|
size: 18,
|
|
color: selected ? palette.accent : palette.textSecondary,
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxs),
|
|
Text(
|
|
entry.relativePath,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxs),
|
|
Text(
|
|
_metaLabel(entry),
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: palette.textMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (entry.previewable)
|
|
Icon(
|
|
Icons.visibility_outlined,
|
|
size: 16,
|
|
color: palette.textMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static IconData _iconForEntry(AssistantArtifactEntry entry) {
|
|
if (entry.mimeType.startsWith('image/')) {
|
|
return Icons.image_outlined;
|
|
}
|
|
if (entry.mimeType == 'text/markdown') {
|
|
return Icons.description_outlined;
|
|
}
|
|
if (entry.mimeType == 'text/html') {
|
|
return Icons.language_rounded;
|
|
}
|
|
return Icons.insert_drive_file_outlined;
|
|
}
|
|
|
|
static String _metaLabel(AssistantArtifactEntry entry) {
|
|
final parts = <String>[
|
|
if (entry.mimeType.trim().isNotEmpty) entry.mimeType,
|
|
if (entry.sizeBytes != null) _formatBytes(entry.sizeBytes!),
|
|
if (entry.updatedAtMs != null)
|
|
_formatTimestamp(entry.updatedAtMs!.toInt()),
|
|
];
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
static String _formatBytes(int bytes) {
|
|
if (bytes < 1024) {
|
|
return '$bytes B';
|
|
}
|
|
if (bytes < 1024 * 1024) {
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
}
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
static String _formatTimestamp(int millis) {
|
|
final date = DateTime.fromMillisecondsSinceEpoch(millis);
|
|
final month = date.month.toString().padLeft(2, '0');
|
|
final day = date.day.toString().padLeft(2, '0');
|
|
final hour = date.hour.toString().padLeft(2, '0');
|
|
final minute = date.minute.toString().padLeft(2, '0');
|
|
return '${date.year}-$month-$day $hour:$minute';
|
|
}
|
|
}
|
|
|
|
class _ArtifactPreviewPanel extends StatelessWidget {
|
|
const _ArtifactPreviewPanel({
|
|
super.key,
|
|
required this.entry,
|
|
required this.preview,
|
|
required this.loading,
|
|
required this.fallbackEntries,
|
|
required this.onSelectEntry,
|
|
});
|
|
|
|
final AssistantArtifactEntry? entry;
|
|
final AssistantArtifactPreview preview;
|
|
final bool loading;
|
|
final List<AssistantArtifactEntry> fallbackEntries;
|
|
final ValueChanged<AssistantArtifactEntry> onSelectEntry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final resolvedEntry = entry;
|
|
final theme = Theme.of(context);
|
|
final palette = context.palette;
|
|
if (loading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (resolvedEntry == null) {
|
|
return _SidebarEmptyState(
|
|
key: const Key('assistant-artifact-pane-empty'),
|
|
icon: Icons.preview_outlined,
|
|
title: appText('暂无预览对象', 'No preview target'),
|
|
message: appText(
|
|
'从全部文件里选择一个文件后,会在这里轻量预览。',
|
|
'Select a file from all files to preview it here.',
|
|
),
|
|
);
|
|
}
|
|
if (preview.kind == AssistantArtifactPreviewKind.empty &&
|
|
preview.message.trim().isNotEmpty) {
|
|
return _SidebarEmptyState(
|
|
key: const Key('assistant-artifact-pane-empty'),
|
|
icon: Icons.preview_outlined,
|
|
title: resolvedEntry.label,
|
|
message: preview.message,
|
|
);
|
|
}
|
|
|
|
final body = switch (preview.kind) {
|
|
AssistantArtifactPreviewKind.markdown => MarkdownBody(
|
|
key: const Key('assistant-artifact-preview-markdown'),
|
|
data: preview.content,
|
|
selectable: true,
|
|
extensionSet: md.ExtensionSet.gitHubWeb,
|
|
),
|
|
AssistantArtifactPreviewKind.html => Html(
|
|
key: const Key('assistant-artifact-preview-html'),
|
|
data: preview.content,
|
|
style: <String, Style>{
|
|
'body': Style(
|
|
margin: Margins.zero,
|
|
padding: HtmlPaddings.zero,
|
|
fontSize: FontSize(13),
|
|
color: palette.textPrimary,
|
|
),
|
|
},
|
|
),
|
|
AssistantArtifactPreviewKind.text => SelectableText(
|
|
preview.content,
|
|
key: const Key('assistant-artifact-preview-text'),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontFamily: 'Menlo',
|
|
height: 1.4,
|
|
),
|
|
),
|
|
AssistantArtifactPreviewKind.unsupported => Column(
|
|
key: const Key('assistant-artifact-preview-unsupported'),
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
preview.message.trim().isEmpty
|
|
? appText(
|
|
'当前文件类型不支持轻量预览。',
|
|
'Lightweight preview is unavailable for this file type.',
|
|
)
|
|
: preview.message,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
if (fallbackEntries.isNotEmpty) ...[
|
|
const SizedBox(height: AppSpacing.md),
|
|
Text(
|
|
appText('可继续查看的文件', 'Other files you can preview'),
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
...fallbackEntries.take(6).map((item) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
|
child: InkWell(
|
|
onTap: () => onSelectEntry(item),
|
|
child: Text(
|
|
item.relativePath,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: palette.accent,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
],
|
|
),
|
|
AssistantArtifactPreviewKind.empty => const SizedBox.shrink(),
|
|
};
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.md,
|
|
0,
|
|
AppSpacing.md,
|
|
AppSpacing.md,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
resolvedEntry.label,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xxs),
|
|
Text(
|
|
resolvedEntry.relativePath,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
body,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SidebarEmptyState extends StatelessWidget {
|
|
const _SidebarEmptyState({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.message,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
final theme = Theme.of(context);
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 28, color: palette.textMuted),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: palette.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|