import 'dart:io'; import 'assistant_artifacts.dart'; import 'runtime_models.dart'; class DesktopThreadArtifactService { static const int defaultResultLimitInternal = 24; static const Set ignoredDirectoryNamesInternal = { '.git', '.dart_tool', 'build', 'Pods', 'DerivedData', '.symlinks', '.gradle', 'out', }; Future loadSnapshot({ required String workspacePath, required WorkspaceRefKind workspaceKind, List artifactRelativePaths = const [], }) async { final normalizedRef = workspacePath.trim(); if (normalizedRef.isEmpty) { return AssistantArtifactSnapshot( workspacePath: normalizedRef, workspaceKind: workspaceKind, resultMessage: 'No recorded working directory for this thread.', filesMessage: 'No recorded working directory for this thread.', changesMessage: 'No recorded working directory for this thread.', ); } if (workspaceKind != WorkspaceRefKind.localPath) { return AssistantArtifactSnapshot( workspacePath: normalizedRef, workspaceKind: workspaceKind, resultMessage: 'This thread workspace is recorded on a remote agent and is not browsable from desktop.', filesMessage: 'This thread workspace is recorded on a remote agent and is not browsable from desktop.', changesMessage: 'This thread workspace is recorded on a remote agent and is not browsable from desktop.', ); } final root = Directory(normalizedRef); if (!await root.exists()) { return AssistantArtifactSnapshot( workspacePath: normalizedRef, workspaceKind: workspaceKind, resultMessage: 'This thread workspace is recorded but is not available on the current machine.', filesMessage: 'This thread workspace is recorded but is not available on the current machine.', changesMessage: 'This thread workspace is recorded but is not available on the current machine.', ); } final taskArtifactPaths = normalizeTaskArtifactPathsInternal( artifactRelativePaths, ); final allFiles = await collectFilesInternal(root); final fileEntries = await buildEntriesInternal(allFiles, normalizedRef); final taskFiles = taskArtifactPaths.isEmpty ? const [] : await collectTaskArtifactFilesInternal( root, normalizedRef, taskArtifactPaths, ); final taskFileEntries = await buildEntriesInternal( taskFiles, normalizedRef, ); final changes = taskArtifactPaths.isEmpty ? const [] : await readGitChangesInternal( root, normalizedRef, artifactRelativePaths: taskArtifactPaths, ); final results = await buildResultEntriesInternal( changes: changes, fileEntries: taskFileEntries, workspacePath: normalizedRef, ); final resultMessage = results.isEmpty ? taskArtifactPaths.isEmpty ? 'No task artifacts recorded for this run.' : 'No current task artifacts found. Showing all files for this thread.' : ''; final filesMessage = fileEntries.isEmpty ? 'No files found in the recorded working directory.' : ''; final changesMessage = changes.isEmpty ? 'No Git changes found for the current thread workspace.' : ''; return AssistantArtifactSnapshot( workspacePath: normalizedRef, workspaceKind: workspaceKind, resultEntries: results, fileEntries: fileEntries, changes: changes, resultMessage: resultMessage, filesMessage: filesMessage, changesMessage: changesMessage, ); } Future loadPreview({ required AssistantArtifactEntry entry, required String workspacePath, required WorkspaceRefKind workspaceKind, List artifactRelativePaths = const [], }) async { if (workspaceKind != WorkspaceRefKind.localPath) { return const AssistantArtifactPreview.empty( message: 'Remote agent artifacts are not directly readable on desktop.', ); } final root = Directory(workspacePath.trim()); if (!await root.exists()) { return const AssistantArtifactPreview.empty( message: 'The recorded working directory is not available on this machine.', ); } final entryRelativePath = normalizeArtifactPathInternal(entry.relativePath); if (entryRelativePath.isEmpty) { return const AssistantArtifactPreview.empty( message: 'The selected file is not part of the current thread workspace.', ); } final targetPath = resolveAbsolutePathInternal( workspacePath, entryRelativePath, ); final file = File(targetPath); if (!await file.exists()) { return AssistantArtifactPreview.empty( message: 'The selected file is no longer available: ${entry.relativePath}', ); } final resolvedRelativePath = relativePathInternal(workspacePath, file.path); if (resolvedRelativePath == null || resolvedRelativePath != entryRelativePath) { return const AssistantArtifactPreview.empty( message: 'The selected file is not part of the current thread workspace.', ); } final extension = fileExtensionInternal(entryRelativePath); final content = await file.readAsString(); final title = entry.label; if (extension == 'md' || extension == 'markdown') { return AssistantArtifactPreview( kind: AssistantArtifactPreviewKind.markdown, title: title, content: content, ); } if (extension == 'html' || extension == 'htm') { return AssistantArtifactPreview( kind: AssistantArtifactPreviewKind.html, title: title, content: sanitizeHtmlInternal(content), ); } if (isPlainTextExtensionInternal(extension)) { return AssistantArtifactPreview( kind: AssistantArtifactPreviewKind.text, title: title, content: content, ); } return AssistantArtifactPreview.unsupported( title: title, message: 'Preview is not available for this file type.', ); } Future> collectFilesInternal(Directory root) async { final files = []; try { await for (final entity in root.list(followLinks: false)) { if (entity is Directory) { if (ignoredDirectoryNamesInternal.contains( baseNameInternal(entity.path), )) { continue; } files.addAll(await collectFilesInternal(entity)); continue; } if (entity is File) { files.add(entity); } } } on FileSystemException { // Best effort only. A single unreadable directory should not block the panel. } return files; } Future> collectTaskArtifactFilesInternal( Directory root, String workspacePath, List artifactRelativePaths, ) async { final files = []; for (final relativePath in artifactRelativePaths) { final target = File(resolveAbsolutePathInternal(root.path, relativePath)); try { if (!await target.exists()) { continue; } final resolvedRelativePath = relativePathInternal( workspacePath, target.path, ); if (resolvedRelativePath == null || resolvedRelativePath.isEmpty) { continue; } files.add(target); } on FileSystemException { continue; } } return files; } Future> buildEntriesInternal( List files, String workspacePath, ) async { final entries = []; for (final file in files) { try { final stat = await file.stat(); final relativePath = relativePathInternal(workspacePath, file.path) ?? file.path; final extension = fileExtensionInternal(relativePath); entries.add( AssistantArtifactEntry( id: '$workspacePath::$relativePath', label: baseNameInternal(relativePath), relativePath: relativePath, kind: AssistantArtifactEntryKind.file, mimeType: guessMimeTypeInternal(relativePath), sizeBytes: stat.size, updatedAtMs: stat.modified.millisecondsSinceEpoch.toDouble(), previewable: isPreviewableExtensionInternal(extension), workspacePath: workspacePath, ), ); } on FileSystemException { // Ignore files that cannot be stat'ed. } } entries.sort((a, b) { final updatedCompare = (b.updatedAtMs ?? 0).compareTo(a.updatedAtMs ?? 0); if (updatedCompare != 0) { return updatedCompare; } return a.relativePath.compareTo(b.relativePath); }); return entries; } Future> buildResultEntriesInternal({ required List changes, required List fileEntries, required String workspacePath, }) async { final filesByPath = { for (final entry in fileEntries) entry.relativePath: entry, }; final results = []; for (final change in changes) { final entry = filesByPath[change.path]; if (entry != null) { results.add(entry); } } if (results.isNotEmpty) { return results; } return fileEntries.take(defaultResultLimitInternal).toList(growable: false); } Future> readGitChangesInternal( Directory workspaceRoot, String workspacePath, { List artifactRelativePaths = const [], }) async { final allowedPaths = normalizeTaskArtifactPathsInternal( artifactRelativePaths, ).toSet(); String? repositoryRoot; try { final revParse = await Process.run('git', [ '-C', workspaceRoot.path, 'rev-parse', '--show-toplevel', ]); if (revParse.exitCode != 0) { return const []; } repositoryRoot = revParse.stdout.toString().trim(); if (repositoryRoot.isEmpty) { return const []; } final status = await Process.run('git', [ '-C', repositoryRoot, 'status', '--short', '--untracked-files=all', ]); if (status.exitCode != 0) { return const []; } final items = []; final lines = status.stdout .toString() .split('\n') .map((item) => item.trimRight()) .where((item) => item.isNotEmpty); for (final line in lines) { if (line.length < 3) { continue; } final statusCode = line.substring(0, 2).trim(); final rawPath = line.substring(3).trim(); final path = rawPath.contains(' -> ') ? rawPath.split(' -> ').last.trim() : rawPath; final absolutePath = joinPathInternal(repositoryRoot, path); final relativePath = relativePathInternal(workspacePath, absolutePath); if (relativePath == null || relativePath.isEmpty) { continue; } if (allowedPaths.isNotEmpty && !allowedPaths.contains(relativePath)) { continue; } items.add( AssistantArtifactChangeEntry( path: relativePath, changeType: statusCode, displayLabel: statusLabelForInternal(statusCode), ), ); } return items; } on ProcessException { return const []; } } static String resolveAbsolutePathInternal(String root, String relativePath) { if (relativePath.startsWith('/') || relativePath.startsWith('\\') || relativePath.contains(':\\')) { return relativePath; } return joinPathInternal(root, relativePath); } static String sanitizeHtmlInternal(String value) { final withoutBlockedTags = value .replaceAll( RegExp( r'<(script|iframe|object|embed|link|meta|base)[^>]*>[\s\S]*?<\/\1>', caseSensitive: false, ), '', ) .replaceAll( RegExp( r'<(script|iframe|object|embed|link|meta|base)[^>]*\/?>', caseSensitive: false, ), '', ); final withoutEventHandlers = withoutBlockedTags.replaceAll( RegExp(r'''\son\w+\s*=\s*(".*?"|'.*?'|[^\s>]+)''', caseSensitive: false), '', ); return withoutEventHandlers.replaceAllMapped( RegExp( r'''\s(href|src)\s*=\s*(".*?"|'.*?'|[^\s>]+)''', caseSensitive: false, ), (match) { final quoteWrapped = match.group(2) ?? ''; final raw = quoteWrapped .replaceAll('"', '') .replaceAll('\'', '') .trim(); final lower = raw.toLowerCase(); if (lower.startsWith('javascript:') || lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('//')) { return ' ${match.group(1)}="#"'; } return match.group(0) ?? ''; }, ); } static String joinPathInternal(String root, String child) { final separator = Platform.pathSeparator; final normalizedRoot = root.endsWith(separator) ? root : '$root$separator'; final normalizedChild = child.startsWith(separator) ? child.substring(1) : child; return '$normalizedRoot$normalizedChild'; } static String? relativePathInternal(String root, String absolutePath) { final normalizedRoot = normalizePathInternal(root); final normalizedPath = normalizePathInternal(absolutePath); if (normalizedRoot == normalizedPath) { return ''; } final prefix = normalizedRoot.endsWith('/') ? normalizedRoot : '$normalizedRoot/'; if (!normalizedPath.startsWith(prefix)) { return null; } return normalizedPath.substring(prefix.length); } static List normalizeTaskArtifactPathsInternal( List relativePaths, ) { final seen = {}; final normalized = []; for (final relativePath in relativePaths) { final item = normalizeArtifactPathInternal(relativePath); if (item.isEmpty || !seen.add(item)) { continue; } normalized.add(item); } return normalized; } static String normalizeArtifactPathInternal(String relativePath) { final trimmed = relativePath.trim().replaceAll('\\', '/'); if (trimmed.isEmpty || trimmed.startsWith('/') || trimmed.startsWith('~') || trimmed.contains(':')) { return ''; } final segments = trimmed .split('/') .where((segment) => segment.isNotEmpty && segment != '.') .toList(growable: false); if (segments.isEmpty || segments.any((segment) => segment == '..')) { return ''; } return segments.join('/'); } static String normalizePathInternal(String path) { try { final type = FileSystemEntity.typeSync(path, followLinks: true); final resolved = switch (type) { FileSystemEntityType.directory => Directory( path, ).resolveSymbolicLinksSync(), FileSystemEntityType.file || FileSystemEntityType.link => File(path).resolveSymbolicLinksSync(), FileSystemEntityType.notFound => File(path).absolute.path, _ => File(path).absolute.path, }; return resolved.replaceAll('\\', '/'); } on FileSystemException { return File(path).absolute.path.replaceAll('\\', '/'); } } static String baseNameInternal(String path) { final normalized = path.replaceAll('\\', '/'); final parts = normalized.split('/'); return parts.isEmpty ? normalized : parts.last; } static String fileExtensionInternal(String path) { final name = baseNameInternal(path); final index = name.lastIndexOf('.'); if (index <= 0 || index >= name.length - 1) { return ''; } return name.substring(index + 1).toLowerCase(); } static String guessMimeTypeInternal(String path) { final extension = fileExtensionInternal(path); return switch (extension) { 'md' || 'markdown' => 'text/markdown', 'html' || 'htm' => 'text/html', 'txt' || 'log' => 'text/plain', 'json' => 'application/json', 'yaml' || 'yml' => 'application/yaml', 'csv' => 'text/csv', 'png' => 'image/png', 'jpg' || 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'dart' => 'text/x-dart', 'js' => 'text/javascript', 'ts' => 'text/typescript', 'css' => 'text/css', 'xml' => 'application/xml', _ => 'application/octet-stream', }; } static bool isPreviewableExtensionInternal(String extension) { return extension == 'md' || extension == 'markdown' || extension == 'html' || extension == 'htm' || isPlainTextExtensionInternal(extension); } static bool isPlainTextExtensionInternal(String extension) { return { 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'dart', 'js', 'ts', 'css', 'xml', 'sh', }.contains(extension); } static String statusLabelForInternal(String code) { if (code == '??') { return 'Untracked'; } if (code.contains('A')) { return 'Added'; } if (code.contains('M')) { return 'Modified'; } if (code.contains('D')) { return 'Deleted'; } if (code.contains('R')) { return 'Renamed'; } if (code.contains('C')) { return 'Copied'; } if (code.contains('U')) { return 'Updated'; } return code.isEmpty ? 'Changed' : code; } }