Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,44 @@ export async function openFileInTaskEditor(
provisioned.workspaceId,
resolvedPath
);
let openPath = resolvedPath;
if (!exists.success || !exists.data.exists) {
toast.error(`File not found in workspace: ${filePath}`);
return;
// Agents often reference files by bare name (`Notes.md`) without the
// directory — fall back to a workspace file-index lookup.
const indexed = isBareFilename(filePath)
? await findWorkspaceFileByName(provisioned.workspaceId, filePath)
: null;
if (indexed === null) {
toast.error(`File not found in workspace: ${filePath}`);
return;
}
openPath = resolveWorkspacePath(workspace.path, indexed);
}

focusTracker.transition({ mainPanel: 'editor' }, 'panel_switch');
provisioned.viewModel?.activePane.open('file', { path: resolvedPath }, { preview: false });
provisioned.viewModel?.activePane.open('file', { path: openPath }, { preview: false });
}

function isBareFilename(filePath: string): boolean {
return !filePath.includes('/') && !filePath.includes('\\');
}

/**
* Resolves a bare filename against the workspace file index. Returns the
* workspace-relative path of the shallowest exact-name match, or null when
* the name is unknown (e.g. a false-positive terminal link like `Node.js`).
*/
async function findWorkspaceFileByName(
workspaceId: string,
filename: string
): Promise<string | null> {
const hits = await rpc.search.searchWorkspaceFiles({ workspaceId, query: filename, limit: 50 });
const exact = hits.filter((h) => h.filename === filename);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
if (exact.length === 0) return null;
exact.sort(
(a, b) => a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path)
);
return exact[0].path;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ILink } from '@xterm/xterm';

// Lookbehind on `:` keeps URLs (`https://...`) with WebLinksAddon.
// Matches pathful references (`src/foo.ts`) and bare filenames (`Notes.md`).
// Bare filenames require a stem of at least two characters so sentence
// abbreviations like `e.g` or `i.e` never linkify.
const FILE_PATH_PATTERN =
'(?<![\\w\\-./@:])(~/|/|\\.{1,2}/)?(?:[\\w\\-.@]+/)+[\\w\\-.@]+\\.[a-zA-Z][a-zA-Z0-9]{0,9}\\b';
'(?<![\\w\\-./@:])(?:(?:~/|/|\\.{1,2}/)?(?:[\\w\\-.@]+/)+[\\w\\-.@]+|[\\w\\-@][\\w\\-.@]+)\\.[a-zA-Z][a-zA-Z0-9]{0,9}\\b';
Comment thread
janburzinski marked this conversation as resolved.
const URL_PROTOCOL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
const MAX_WRAPPED_LINE_LENGTH = 4096;

Expand Down Expand Up @@ -30,7 +33,7 @@ type LogicalLine = {

export function findFileLinks(buffer: BufferLike, bufferLineNumber: number): FileLinkMatch[] {
const logicalLine = getWrappedLogicalLine(buffer, bufferLineNumber - 1);
if (!logicalLine || !logicalLine.text || logicalLine.text.indexOf('/') === -1) {
if (!logicalLine || !logicalLine.text || logicalLine.text.indexOf('.') === -1) {
return [];
}

Expand Down
20 changes: 17 additions & 3 deletions apps/emdash-desktop/src/renderer/lib/pty/file-link-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type NavigatorWithUserAgentData = Navigator & {

export class ActivationModifierTracker {
private hoveredDecorations: LinkDecorations | null = null;
private hoveredRefresh: (() => void) | null = null;
private pressed = false;

constructor(private readonly isMac = isMacPlatform()) {}
Expand All @@ -24,30 +25,39 @@ export class ActivationModifierTracker {
}

update(event: ActivationModifierEvent): boolean {
this.pressed = this.isPressed(event);
const next = this.isPressed(event);
const changed = next !== this.pressed;
this.pressed = next;
this.syncHoveredDecorations();
// xterm only re-reads link decorations on pointer events, so toggling the
// modifier while the pointer sits still needs an explicit repaint to
// show/hide the underline on the already-hovered link.
if (changed) this.hoveredRefresh?.();
return this.pressed;
}

isPressed(event: ActivationModifierEvent): boolean {
return isActivationModifierPressed(event, this.isMac);
}

hover(decorations: LinkDecorations, event: ActivationModifierEvent): void {
hover(decorations: LinkDecorations, event: ActivationModifierEvent, refresh?: () => void): void {
this.hoveredDecorations = decorations;
this.hoveredRefresh = refresh ?? null;
this.update(event);
}

leave(decorations: LinkDecorations): void {
if (this.hoveredDecorations === decorations) {
this.hoveredDecorations = null;
this.hoveredRefresh = null;
}
setDecorations(decorations, false);
}

reset(): void {
this.pressed = false;
this.syncHoveredDecorations();
this.hoveredRefresh?.();
}

private syncHoveredDecorations(): void {
Expand Down Expand Up @@ -75,14 +85,18 @@ export class FileLinkProvider implements ILinkProvider {
callback(links.length > 0 ? links : undefined);
}

private refreshViewport(): void {
this.terminal.refresh(0, this.terminal.rows - 1);
}

private toXtermLink(match: FileLinkMatch): ILink {
const decorations = this.tracker.decorations();
const link: ILink = {
range: match.range,
text: match.text,
decorations,
hover: (event) => {
this.tracker.hover(link.decorations ?? decorations, event);
this.tracker.hover(link.decorations ?? decorations, event, () => this.refreshViewport());
},
leave: () => {
this.tracker.leave(link.decorations ?? decorations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,48 @@ describe('file link provider', () => {
new MockBufferLine(' baz.ts'),
]);

expect(findFileLinks(buffer, 3)).toEqual([]);
// Only the bare filename on the last line — never the joined path.
expect(findFileLinks(buffer, 3)).toEqual([
{
range: {
start: { x: 3, y: 3 },
end: { x: 8, y: 3 },
},
text: 'baz.ts',
isExternal: false,
},
]);
});

it('detects bare filenames without a directory segment', () => {
const buffer = makeBuffer([
new MockBufferLine('Search Qin Liu in UZH_Silicon_Valley_Attendees_LinkedIn.md'),
]);

expect(findFileLinks(buffer, 1)).toEqual([
{
range: {
start: { x: 19, y: 1 },
end: { x: 58, y: 1 },
},
text: 'UZH_Silicon_Valley_Attendees_LinkedIn.md',
isExternal: false,
},
]);
});

it('ignores single-letter abbreviations like e.g. and i.e.', () => {
const buffer = makeBuffer([
new MockBufferLine('use a helper, e.g. one from utils, i.e. the shared one'),
]);

expect(findFileLinks(buffer, 1)).toEqual([]);
});

it('keeps bare domains inside URLs delegated to the web links addon', () => {
const buffer = makeBuffer([new MockBufferLine('deployed to https://emdash.sh just now')]);

expect(findFileLinks(buffer, 1)).toEqual([]);
});

it('classifies absolute and home-relative paths as external', () => {
Expand Down Expand Up @@ -164,6 +205,41 @@ describe('file link provider', () => {
expect(decorations).toEqual({ pointerCursor: false, underline: false });
});

it('repaints the hovered link when the modifier toggles without mouse movement', () => {
const tracker = new ActivationModifierTracker(true);
const decorations = tracker.decorations();
let refreshes = 0;

tracker.hover(decorations, { metaKey: false, ctrlKey: false }, () => {
refreshes += 1;
});
expect(refreshes).toBe(0);

tracker.update({ metaKey: true, ctrlKey: false });
expect(refreshes).toBe(1);

tracker.update({ metaKey: false, ctrlKey: false });
expect(refreshes).toBe(2);

// No change in pressed state must not trigger redundant repaints.
tracker.update({ metaKey: false, ctrlKey: false });
expect(refreshes).toBe(2);
});

it('stops repainting once the pointer leaves the link', () => {
const tracker = new ActivationModifierTracker(true);
const decorations = tracker.decorations();
let refreshes = 0;

tracker.hover(decorations, { metaKey: false, ctrlKey: false }, () => {
refreshes += 1;
});
tracker.leave(decorations);

tracker.update({ metaKey: true, ctrlKey: false });
expect(refreshes).toBe(0);
});

it('only opens links when the activation modifier is pressed', () => {
const openedFiles: string[] = [];
const openedExternal: string[] = [];
Expand Down
Loading