clickable file references in the Claude conversation (T-300)
Workspace file paths mentioned by Claude now linkify and open in the editor: bare (lib/app.dart), with a line (lib/app.dart:42), backticked, or as markdown links. Only paths that exist in the repo linkify — the resolver gates on existence so prose (version numbers, "e.g.") stays literal. Clicking maps to the editor.open verb, jumping to the line when a :line suffix is present (D-6 parity). ClideMarkdown gains resolveFileRef + onOpenFile hooks; conversation_view resolves against the open project root + existsSync and dispatches over IPC. Detection covers running prose, whole-content code spans, and link hrefs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
@@ -365,6 +366,25 @@ void _openUrl(BuildContext context, String url) {
|
||||
unawaited(ClideKernel.of(context).os.openURL(url));
|
||||
}
|
||||
|
||||
/// Resolve a path-like token from the conversation to an absolute workspace
|
||||
/// file, or null if it doesn't name a real repo file (T-300). The existence
|
||||
/// check is what keeps prose ("e.g.", "2.2.0") from linkifying. Resolves
|
||||
/// relative tokens against the open project root; absolute tokens must already
|
||||
/// live inside it. `..` segments are rejected so a ref can't escape the repo.
|
||||
String? _resolveRepoFile(BuildContext context, String raw) {
|
||||
final root = ClideKernel.of(context).project.current?.path;
|
||||
if (root == null || raw.isEmpty || raw.contains('..')) return null;
|
||||
final abs = raw.startsWith('/') ? raw : '$root/$raw';
|
||||
if (!abs.startsWith('$root/')) return null;
|
||||
return File(abs).existsSync() ? abs : null;
|
||||
}
|
||||
|
||||
/// Open a clicked workspace file reference in the editor, jumping to [line]
|
||||
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
|
||||
void _openFile(BuildContext context, String path, int? line) {
|
||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
|
||||
}
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
class _ConversationTurn extends StatelessWidget {
|
||||
const _ConversationTurn({
|
||||
@@ -443,6 +463,8 @@ class _ConversationTurn extends StatelessWidget {
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
@@ -453,7 +475,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
label: i.isSidechain ? 'agent' : 'claude',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
|
||||
body: ClideMarkdown(
|
||||
i.text,
|
||||
onRecordTap: (id) => _openRecord(context, id),
|
||||
onLinkTap: (url) => _openUrl(context, url),
|
||||
resolveFileRef: (p) => _resolveRepoFile(context, p),
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
AssistantThinkingMessage() => ConversationCard(
|
||||
// Framed + muted like the context card (T-306).
|
||||
|
||||
Reference in New Issue
Block a user