|
@@ -12,11 +12,12 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
|
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
|
|
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
|
|
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
|
|
|
import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
|
|
import { QuickInputService } from '@theia/core/lib/common/quick-pick-service';
|
|
|
|
|
+import { StorageService } from '@theia/core/lib/browser/storage-service';
|
|
|
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
|
import URI from '@theia/core/lib/common/uri';
|
|
import URI from '@theia/core/lib/common/uri';
|
|
|
import { Message } from '@lumino/messaging';
|
|
import { Message } from '@lumino/messaging';
|
|
|
import { groupExplorerFiles } from './modula2-explorer-model';
|
|
import { groupExplorerFiles } from './modula2-explorer-model';
|
|
|
-import { isValidModuleName, renderDefModule, renderImplModule } from './modula2-project';
|
|
|
|
|
|
|
+import { ACTIVE_PROJECT_KEY, isValidModuleName, renderDefModule, renderImplModule } from './modula2-project';
|
|
|
|
|
|
|
|
export const MODULA2_EXPLORER_ID = 'modula2-explorer';
|
|
export const MODULA2_EXPLORER_ID = 'modula2-explorer';
|
|
|
|
|
|
|
@@ -27,11 +28,15 @@ export interface Modula2TreeNode extends CompositeTreeNode, SelectableTreeNode,
|
|
|
kind: 'project' | 'group' | 'file';
|
|
kind: 'project' | 'group' | 'file';
|
|
|
uri?: string;
|
|
uri?: string;
|
|
|
openable: boolean;
|
|
openable: boolean;
|
|
|
|
|
+ /** Workspace root this node belongs to (project nodes: the root itself). */
|
|
|
|
|
+ rootUri?: string;
|
|
|
|
|
+ /** True for the project node builds and menus operate on. */
|
|
|
|
|
+ active?: boolean;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function folderNode(id: string, name: string, kind: 'project' | 'group'): Modula2TreeNode {
|
|
|
|
|
|
|
+function folderNode(id: string, name: string, kind: 'project' | 'group', rootUri?: string, active?: boolean): Modula2TreeNode {
|
|
|
return {
|
|
return {
|
|
|
- id, name, kind, openable: false,
|
|
|
|
|
|
|
+ id, name, kind, openable: false, rootUri, active,
|
|
|
parent: undefined as unknown as CompositeTreeNode,
|
|
parent: undefined as unknown as CompositeTreeNode,
|
|
|
children: [],
|
|
children: [],
|
|
|
expanded: true,
|
|
expanded: true,
|
|
@@ -40,10 +45,10 @@ function folderNode(id: string, name: string, kind: 'project' | 'group'): Modula
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function fileNode(
|
|
function fileNode(
|
|
|
- uri: string, name: string, openable: boolean,
|
|
|
|
|
|
|
+ uri: string, name: string, openable: boolean, rootUri?: string,
|
|
|
): Modula2TreeNode {
|
|
): Modula2TreeNode {
|
|
|
return {
|
|
return {
|
|
|
- id: uri, name, kind: 'file', uri, openable,
|
|
|
|
|
|
|
+ id: uri, name, kind: 'file', uri, openable, rootUri,
|
|
|
parent: undefined as unknown as CompositeTreeNode,
|
|
parent: undefined as unknown as CompositeTreeNode,
|
|
|
children: [],
|
|
children: [],
|
|
|
expanded: false,
|
|
expanded: false,
|
|
@@ -58,6 +63,7 @@ export class Modula2ExplorerWidget extends TreeWidget {
|
|
|
@inject(FileService) protected readonly files: FileService;
|
|
@inject(FileService) protected readonly files: FileService;
|
|
|
@inject(WorkspaceService) protected readonly workspace: WorkspaceService;
|
|
@inject(WorkspaceService) protected readonly workspace: WorkspaceService;
|
|
|
@inject(EditorManager) protected readonly editors: EditorManager;
|
|
@inject(EditorManager) protected readonly editors: EditorManager;
|
|
|
|
|
+ @inject(StorageService) protected readonly storage: StorageService;
|
|
|
|
|
|
|
|
protected refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
protected refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
protected attached = false;
|
|
protected attached = false;
|
|
@@ -85,7 +91,9 @@ export class Modula2ExplorerWidget extends TreeWidget {
|
|
|
// base TreeWidget.init() (row-update wiring) must be called explicitly.
|
|
// base TreeWidget.init() (row-update wiring) must be called explicitly.
|
|
|
super.init();
|
|
super.init();
|
|
|
// Injections are complete here (never touch them in the constructor).
|
|
// Injections are complete here (never touch them in the constructor).
|
|
|
|
|
+ this.ensureStyle();
|
|
|
this.toDispose.push(this.model.onSelectionChanged(nodes => this.openSelected(nodes)));
|
|
this.toDispose.push(this.model.onSelectionChanged(nodes => this.openSelected(nodes)));
|
|
|
|
|
+ this.toDispose.push(this.model.onOpenNode(node => this.openNode(node as Modula2TreeNode | undefined)));
|
|
|
this.toDispose.push(this.files.onDidFilesChange(() => this.scheduleRefresh()));
|
|
this.toDispose.push(this.files.onDidFilesChange(() => this.scheduleRefresh()));
|
|
|
this.toDispose.push(this.workspace.onWorkspaceChanged(() => this.refresh()));
|
|
this.toDispose.push(this.workspace.onWorkspaceChanged(() => this.refresh()));
|
|
|
this.toDispose.push(this.workspace.onWorkspaceLocationChanged(() => this.refresh()));
|
|
this.toDispose.push(this.workspace.onWorkspaceLocationChanged(() => this.refresh()));
|
|
@@ -116,44 +124,114 @@ export class Modula2ExplorerWidget extends TreeWidget {
|
|
|
try {
|
|
try {
|
|
|
const roots = await this.workspace.roots;
|
|
const roots = await this.workspace.roots;
|
|
|
if (roots.length === 0) {
|
|
if (roots.length === 0) {
|
|
|
- this.model.root = folderNode('m2root', 'No folder open', 'project');
|
|
|
|
|
|
|
+ this.model.root = folderNode('modula2root', 'No folder open', 'project');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- const root = roots[0].resource;
|
|
|
|
|
- const project = await this.readProject(root);
|
|
|
|
|
- const found = await this.collectFiles(root, root, 0, [], project.output);
|
|
|
|
|
- const groups = groupExplorerFiles(found, project.main);
|
|
|
|
|
- const rootNode = folderNode(
|
|
|
|
|
- 'm2root',
|
|
|
|
|
- project.name ?? `${root.path.base} (no modula2.json)`,
|
|
|
|
|
- 'project',
|
|
|
|
|
- );
|
|
|
|
|
- for (const group of groups) {
|
|
|
|
|
- const groupNode = folderNode(`m2group:${group.id}`, group.label, 'group');
|
|
|
|
|
- const fileNodes = group.files.map(f => fileNode(
|
|
|
|
|
- f.uri,
|
|
|
|
|
- f.main ? `${f.name} (main)` : f.name,
|
|
|
|
|
- f.openable,
|
|
|
|
|
- ));
|
|
|
|
|
- CompositeTreeNode.addChildren(groupNode, fileNodes);
|
|
|
|
|
- CompositeTreeNode.addChild(rootNode, groupNode);
|
|
|
|
|
- }
|
|
|
|
|
- if (rootNode.children.length === 0) {
|
|
|
|
|
- const hint = folderNode('m2empty', 'No Modula-2 files found', 'group');
|
|
|
|
|
- hint.expanded = false;
|
|
|
|
|
- CompositeTreeNode.addChild(rootNode, hint);
|
|
|
|
|
|
|
+ const activeUri = await this.activeRootUri(roots.map(r => r.resource.toString()));
|
|
|
|
|
+ const top = folderNode('modula2root', 'Modula-2 Projects', 'project');
|
|
|
|
|
+ for (const { resource: root } of roots) {
|
|
|
|
|
+ const uri = root.toString();
|
|
|
|
|
+ const active = uri === activeUri;
|
|
|
|
|
+ const project = await this.readProject(root);
|
|
|
|
|
+ const found = await this.collectFiles(root, root, 0, [], project.output);
|
|
|
|
|
+ const groups = groupExplorerFiles(found, project.main);
|
|
|
|
|
+ const title = project.name ?? `${root.path.base} (no modula2.json)`;
|
|
|
|
|
+ const rootNode = folderNode(
|
|
|
|
|
+ `modula2root:${uri}`,
|
|
|
|
|
+ active ? `${title} (active)` : title,
|
|
|
|
|
+ 'project',
|
|
|
|
|
+ uri,
|
|
|
|
|
+ active,
|
|
|
|
|
+ );
|
|
|
|
|
+ for (const group of groups) {
|
|
|
|
|
+ const groupNode = folderNode(`modula2group:${uri}:${group.id}`, group.label, 'group', uri);
|
|
|
|
|
+ const fileNodes = group.files.map(f => fileNode(
|
|
|
|
|
+ f.uri,
|
|
|
|
|
+ f.main ? `${f.name} (main)` : f.name,
|
|
|
|
|
+ f.openable,
|
|
|
|
|
+ uri,
|
|
|
|
|
+ ));
|
|
|
|
|
+ CompositeTreeNode.addChildren(groupNode, fileNodes);
|
|
|
|
|
+ CompositeTreeNode.addChild(rootNode, groupNode);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (rootNode.children.length === 0) {
|
|
|
|
|
+ const hint = folderNode(`modula2empty:${uri}`, 'No Modula-2 files found', 'group', uri);
|
|
|
|
|
+ hint.expanded = false;
|
|
|
|
|
+ CompositeTreeNode.addChild(rootNode, hint);
|
|
|
|
|
+ }
|
|
|
|
|
+ CompositeTreeNode.addChild(top, rootNode);
|
|
|
}
|
|
}
|
|
|
- this.model.root = rootNode;
|
|
|
|
|
|
|
+ this.model.root = top;
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
console.error('Modula-2 explorer refresh failed:', message);
|
|
console.error('Modula-2 explorer refresh failed:', message);
|
|
|
- const errNode = folderNode('m2error', 'Explorer error', 'group');
|
|
|
|
|
- const detail = fileNode('m2error:detail', message.slice(0, 120), false);
|
|
|
|
|
|
|
+ const errNode = folderNode('modula2error', 'Explorer error', 'group');
|
|
|
|
|
+ const detail = fileNode('modula2error:detail', message.slice(0, 120), false);
|
|
|
CompositeTreeNode.addChild(errNode, detail);
|
|
CompositeTreeNode.addChild(errNode, detail);
|
|
|
this.model.root = errNode;
|
|
this.model.root = errNode;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** Active project root URI: the stored one when still open, else the first root. */
|
|
|
|
|
+ protected async activeRootUri(known?: string[]): Promise<string | undefined> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const roots = known ?? (await this.workspace.roots).map(r => r.resource.toString());
|
|
|
|
|
+ if (roots.length === 0) return undefined;
|
|
|
|
|
+ const stored = await this.storage.getData<string>(ACTIVE_PROJECT_KEY, '');
|
|
|
|
|
+ if (stored && roots.includes(stored)) return stored;
|
|
|
|
|
+ return roots[0];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Make a workspace root the active project (builds and menus follow it). */
|
|
|
|
|
+ protected async setActiveProject(rootUri: string): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.storage.setData(ACTIVE_PROJECT_KEY, rootUri);
|
|
|
|
|
+ } catch { /* refresh still shows the marker for this session */ }
|
|
|
|
|
+ await this.refresh();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Double-click/Enter on a project node activates it; files keep single-click reveal. */
|
|
|
|
|
+ protected openNode(node: Modula2TreeNode | undefined): void {
|
|
|
|
|
+ if (node?.kind === 'project' && node.rootUri) {
|
|
|
|
|
+ void this.setActiveProject(node.rootUri);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected override handleDblClickEvent(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
|
|
|
|
|
+ // Project nodes are expandable, so a double-click would only
|
|
|
|
|
+ // collapse them: activate the project instead.
|
|
|
|
|
+ if ((node as Partial<Modula2TreeNode>)?.kind === 'project' && (node as Partial<Modula2TreeNode>)?.rootUri) {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ void this.setActiveProject((node as Modula2TreeNode).rootUri!);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ super.handleDblClickEvent(node, event);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected override createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
|
|
|
|
|
+ const classes = super.createNodeClassNames(node, props);
|
|
|
|
|
+ if ((node as Partial<Modula2TreeNode>).active) classes.push('modula2-active-project');
|
|
|
|
|
+ return classes;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Widget-scoped stylesheet: the active project marker. Rows are flex
|
|
|
|
|
+ * measured, so the bolder caption never clips. Injected here (not via
|
|
|
|
|
+ * a CSS import) so the extension build needs no bundler CSS support. */
|
|
|
|
|
+ protected ensureStyle(): void {
|
|
|
|
|
+ if (document.getElementById('modula2-explorer-style')) return;
|
|
|
|
|
+ const element = document.createElement('style');
|
|
|
|
|
+ element.id = 'modula2-explorer-style';
|
|
|
|
|
+ element.textContent = [
|
|
|
|
|
+ '.modula2-project-explorer .theia-TreeNode.modula2-active-project { box-shadow: inset 3px 0 var(--theia-focusBorder); }',
|
|
|
|
|
+ '.modula2-project-explorer .theia-TreeNode.modula2-active-project .theia-TreeNodeSegment { font-weight: bold; filter: brightness(1.35); }',
|
|
|
|
|
+ ].join('\n');
|
|
|
|
|
+ document.head.appendChild(element);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
protected async readProject(root: URI): Promise<{ name?: string; main?: string; output?: string }> {
|
|
protected async readProject(root: URI): Promise<{ name?: string; main?: string; output?: string }> {
|
|
|
try {
|
|
try {
|
|
|
const raw = JSON.parse((await this.files.read(root.resolve('modula2.json'))).value) as unknown;
|
|
const raw = JSON.parse((await this.files.read(root.resolve('modula2.json'))).value) as unknown;
|
|
@@ -280,6 +358,10 @@ export const NewFolderHereCommand: Command = Command.toLocalizedCommand({
|
|
|
id: 'modula2.newFolderHere', label: 'New Folder Here...', category: 'Modula-2',
|
|
id: 'modula2.newFolderHere', label: 'New Folder Here...', category: 'Modula-2',
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+export const SetActiveProjectCommand: Command = Command.toLocalizedCommand({
|
|
|
|
|
+ id: 'modula2.setActiveProject', label: 'Set as Active Project', category: 'Modula-2',
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
@injectable()
|
|
@injectable()
|
|
|
export class Modula2ExplorerCommands implements CommandContribution {
|
|
export class Modula2ExplorerCommands implements CommandContribution {
|
|
|
@inject(WidgetManager) protected readonly widgets: WidgetManager;
|
|
@inject(WidgetManager) protected readonly widgets: WidgetManager;
|
|
@@ -288,6 +370,7 @@ export class Modula2ExplorerCommands implements CommandContribution {
|
|
|
@inject(EditorManager) protected readonly editors: EditorManager;
|
|
@inject(EditorManager) protected readonly editors: EditorManager;
|
|
|
@inject(MessageService) protected readonly messages: MessageService;
|
|
@inject(MessageService) protected readonly messages: MessageService;
|
|
|
@inject(QuickInputService) protected readonly quickInput: QuickInputService;
|
|
@inject(QuickInputService) protected readonly quickInput: QuickInputService;
|
|
|
|
|
+ @inject(StorageService) protected readonly storage: StorageService;
|
|
|
|
|
|
|
|
registerCommands(commands: CommandRegistry): void {
|
|
registerCommands(commands: CommandRegistry): void {
|
|
|
commands.registerCommand(RefreshExplorerCommand, {
|
|
commands.registerCommand(RefreshExplorerCommand, {
|
|
@@ -299,6 +382,37 @@ export class Modula2ExplorerCommands implements CommandContribution {
|
|
|
});
|
|
});
|
|
|
commands.registerCommand(NewModuleHereCommand, { execute: () => this.newModuleHere() });
|
|
commands.registerCommand(NewModuleHereCommand, { execute: () => this.newModuleHere() });
|
|
|
commands.registerCommand(NewFolderHereCommand, { execute: () => this.newFolderHere() });
|
|
commands.registerCommand(NewFolderHereCommand, { execute: () => this.newFolderHere() });
|
|
|
|
|
+ commands.registerCommand(SetActiveProjectCommand, { execute: () => this.setActiveProject() });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Workspace root of the selected explorer node, else the first root. */
|
|
|
|
|
+ protected async selectedRoot(): Promise<string | undefined> {
|
|
|
|
|
+ for (const widget of this.widgets.getWidgets(MODULA2_EXPLORER_ID)) {
|
|
|
|
|
+ const view = widget as Modula2ExplorerWidget;
|
|
|
|
|
+ const node = view.model.selectedNodes[0] as Modula2TreeNode | undefined;
|
|
|
|
|
+ if (node?.rootUri) return node.rootUri;
|
|
|
|
|
+ }
|
|
|
|
|
+ const roots = await this.workspace.roots;
|
|
|
|
|
+ return roots[0]?.resource.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Activate a project: double-click a project node, or run this. */
|
|
|
|
|
+ protected async setActiveProject(): Promise<void> {
|
|
|
|
|
+ const uri = await this.selectedRoot();
|
|
|
|
|
+ if (!uri) {
|
|
|
|
|
+ this.messages.warn('Modula-2: open a folder or workspace first.');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.storage.setData(ACTIVE_PROJECT_KEY, uri);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ this.messages.error('Modula-2: cannot remember the active project.');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const widget of this.widgets.getWidgets(MODULA2_EXPLORER_ID)) {
|
|
|
|
|
+ await (widget as Modula2ExplorerWidget).refresh();
|
|
|
|
|
+ }
|
|
|
|
|
+ this.messages.info(`Modula-2: active project is now ${new URI(uri).path.base}.`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Folder of the selected explorer file, else the workspace root. */
|
|
/** Folder of the selected explorer file, else the workspace root. */
|
|
@@ -395,6 +509,7 @@ export class Modula2ExplorerMenus implements MenuContribution {
|
|
|
const path = MODULA2_EXPLORER_CONTEXT_MENU;
|
|
const path = MODULA2_EXPLORER_CONTEXT_MENU;
|
|
|
menus.registerMenuAction(path, { commandId: NewModuleHereCommand.id, order: '10' });
|
|
menus.registerMenuAction(path, { commandId: NewModuleHereCommand.id, order: '10' });
|
|
|
menus.registerMenuAction(path, { commandId: NewFolderHereCommand.id, order: '11' });
|
|
menus.registerMenuAction(path, { commandId: NewFolderHereCommand.id, order: '11' });
|
|
|
|
|
+ menus.registerMenuAction(path, { commandId: SetActiveProjectCommand.id, order: '12' });
|
|
|
menus.registerMenuAction(path, { commandId: 'modula2.build', order: '20' });
|
|
menus.registerMenuAction(path, { commandId: 'modula2.build', order: '20' });
|
|
|
menus.registerMenuAction(path, { commandId: RefreshExplorerCommand.id, order: '30' });
|
|
menus.registerMenuAction(path, { commandId: RefreshExplorerCommand.id, order: '30' });
|
|
|
}
|
|
}
|