Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .changeset/orphaned-files-startup-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'theme-check-vscode': minor
---

Surface orphaned (dead) files on startup with a notification

When a theme has orphaned files, the VS Code extension now shows a single dismissable notification on startup with **Review** (opens the existing dead-code picker) and **Don't show again** actions, instead of requiring the user to run the dead-code command manually. Gated by the new `themeCheck.checkOrphanedFilesOnBoot` setting (default `true`).
7 changes: 7 additions & 0 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@
],
"description": "When true, theme check preloads all the files from your theme for fast rename handling and theme graph generation.",
"default": true
},
"themeCheck.checkOrphanedFilesOnBoot": {
"type": [
"boolean"
],
"description": "When true, show a notification on startup when your theme has orphaned (dead) files.",
"default": true
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode-extension/src/browser/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import LiquidFormatter from '../common/formatter';
import { vscodePrettierFormat } from './formatter';
import { documentSelectors } from '../common/constants';
import { makeDeadCode, openLocation } from '../common/commands';
import { checkOrphanedFilesOnBoot } from '../common/orphanedFilesOnBoot';
import {
createReferencesTreeView,
setupContext,
Expand Down Expand Up @@ -45,6 +46,9 @@ export async function activate(context: ExtensionContext) {
createReferencesTreeView('shopify.themeGraph.dependencies', context, client, 'dependencies'),
watchReferencesTreeViewConfig(),
);

// Fire-and-forget: surfacing orphaned files must never block or fail activation.
checkOrphanedFilesOnBoot(client).catch((error) => console.error(error));
}
}

Expand Down
94 changes: 94 additions & 0 deletions packages/vscode-extension/src/common/commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { BaseLanguageClient } from 'vscode-languageclient';

const mocks = vi.hoisted(() => ({
showInformationMessage: vi.fn(),
showQuickPick: vi.fn(),
showTextDocument: vi.fn(),
openTextDocument: vi.fn(() => Promise.resolve({})),
executeCommand: vi.fn(),
activeTextEditor: {
document: { uri: { toString: () => 'file:///theme/layout/theme.liquid' } },
} as any,
}));

vi.mock('vscode', () => ({
window: {
get activeTextEditor() {
return mocks.activeTextEditor;
},
showInformationMessage: mocks.showInformationMessage,
showQuickPick: mocks.showQuickPick,
showTextDocument: mocks.showTextDocument,
},
workspace: { openTextDocument: mocks.openTextDocument },
commands: { executeCommand: mocks.executeCommand },
Uri: { parse: (s: string) => ({ toString: () => s }) },
Position: class {
constructor(
public line: number,
public character: number,
) {}
},
Range: class {
constructor(
public start: any,
public end: any,
) {}
},
}));

import { makeDeadCode } from './commands';

function makeClient(rootUri: string, deadCode: string[]): BaseLanguageClient {
return {
sendRequest: vi
.fn()
.mockResolvedValueOnce(rootUri) // ThemeGraphRootRequest
.mockResolvedValueOnce(deadCode), // ThemeGraphDeadCodeRequest
} as unknown as BaseLanguageClient;
}

describe('makeDeadCode (characterization — behavior must survive refactor)', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.activeTextEditor = {
document: { uri: { toString: () => 'file:///theme/layout/theme.liquid' } },
};
});

it('tells the user when there is no dead code', async () => {
const client = makeClient('file:///theme', []);

await makeDeadCode(client)();

expect(mocks.showInformationMessage).toHaveBeenCalledWith('No dead code found.');
expect(mocks.showQuickPick).not.toHaveBeenCalled();
});

it('offers a quick pick of the orphaned files when dead code is found', async () => {
const client = makeClient('file:///theme', [
'file:///theme/snippets/unused-a.liquid',
'file:///theme/snippets/unused-b.liquid',
]);

await makeDeadCode(client)();

expect(mocks.showInformationMessage).not.toHaveBeenCalled();
expect(mocks.showQuickPick).toHaveBeenCalledTimes(1);
const [items, options] = mocks.showQuickPick.mock.calls[0];
expect(items).toHaveLength(2);
expect(options).toMatchObject({ canPickMany: true });
});

it('does nothing when there is no active editor', async () => {
mocks.activeTextEditor = undefined;
const client = makeClient('file:///theme', ['file:///theme/snippets/unused.liquid']);

await makeDeadCode(client)();

expect(client.sendRequest).not.toHaveBeenCalled();
expect(mocks.showInformationMessage).not.toHaveBeenCalled();
expect(mocks.showQuickPick).not.toHaveBeenCalled();
});
});
56 changes: 39 additions & 17 deletions packages/vscode-extension/src/common/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,53 @@ export function openLocation(ref: AugmentedLocation) {
});
}

/**
* Fetches the theme root and the list of dead (orphaned) files for a given uri.
* The uri only needs to belong to the theme — the server resolves the root and
* computes dead code across the whole theme graph.
*/
export async function fetchDeadCode(
client: BaseLanguageClient,
uri: string,
): Promise<{ rootUri: string; deadCode: string[] }> {
const [rootUri, deadCode] = await Promise.all([
client.sendRequest(ThemeGraphRootRequest.type, { uri }),
client.sendRequest(ThemeGraphDeadCodeRequest.type, { uri }),
]);
return { rootUri, deadCode };
}

/**
* Presents the dead files as a multi-select quick pick and opens whatever the
* user selects. Does not depend on the active editor, so it can be driven from
* a startup check as well as the command.
*/
export async function showDeadCodePicker(rootUri: string, deadCode: string[]): Promise<void> {
const relativePaths = deadCode.map((file) => path.relative(file, rootUri));
const selectedFiles = await window.showQuickPick(relativePaths, {
canPickMany: true,
placeHolder: 'Select files to open',
});
if (selectedFiles) {
selectedFiles.forEach((file) => {
const uri = path.join(rootUri, file);
workspace.openTextDocument(Uri.parse(uri)).then((doc) => {
window.showTextDocument(doc, { preview: false, preserveFocus: true, viewColumn: 2 });
});
});
}
}

export function makeDeadCode(client: BaseLanguageClient) {
return async function deadCode() {
const uri = window.activeTextEditor?.document.uri.toString();
if (!uri) return;
const [rootUri, deadCode] = await Promise.all([
client.sendRequest(ThemeGraphRootRequest.type, { uri }),
client.sendRequest(ThemeGraphDeadCodeRequest.type, { uri }),
]);
const { rootUri, deadCode } = await fetchDeadCode(client, uri);

if (deadCode.length === 0) {
window.showInformationMessage('No dead code found.');
} else {
const relativePaths = deadCode.map((file) => path.relative(file, rootUri));
const selectedFiles = await window.showQuickPick(relativePaths, {
canPickMany: true,
placeHolder: 'Select files to open',
});
if (selectedFiles) {
selectedFiles.forEach((file) => {
const uri = path.join(rootUri, file);
workspace.openTextDocument(Uri.parse(uri)).then((doc) => {
window.showTextDocument(doc, { preview: false, preserveFocus: true, viewColumn: 2 });
});
});
}
await showDeadCodePicker(rootUri, deadCode);
}
};
}
Loading
Loading