Skip to content

Commit ae06a5c

Browse files
Added project root system
1 parent 33bbaf9 commit ae06a5c

4 files changed

Lines changed: 188 additions & 26 deletions

File tree

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@
273273
"title": "Canonical Sync: Disabled",
274274
"icon": "./icons/canon_inactive.svg"
275275
},
276+
{
277+
"command": "totk-editor.setLogicalProjectRoot",
278+
"title": "Set Project Root",
279+
"icon": "$(star-empty)"
280+
},
276281
{
277282
"command": "totk-editor.removeArchiveRoot",
278283
"title": "Remove from Projects"
@@ -510,9 +515,14 @@
510515
"command": "totk-editor.removeArchiveRoot",
511516
"when": "view == totk-editor.archives && viewItem == archiveRoot"
512517
},
518+
{
519+
"command": "totk-editor.setLogicalProjectRoot",
520+
"when": "view == totk-editor.archives && viewItem == archiveProjectDir",
521+
"group": "1_active@1"
522+
},
513523
{
514524
"command": "totk-editor.setActiveProject",
515-
"when": "view == totk-editor.archives && viewItem == archiveRoot",
525+
"when": "view == totk-editor.archives && (viewItem == archiveRoot || viewItem == archiveProjectDir || viewItem == archiveProjectDirActive)",
516526
"group": "1_active"
517527
},
518528
{

src/archiveFsCommands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,7 +1288,7 @@ export function registerArchiveFileCommands(context: vscode.ExtensionContext): v
12881288
let current = diskArchive;
12891289
while (current) {
12901290
try {
1291-
if (fs.existsSync(path.join(current, 'options')) || fs.existsSync(path.join(current, '.tkproj'))) {
1291+
if (fs.existsSync(path.join(current, 'options')) || fs.existsSync(path.join(current, '.tkproj')) || fs.existsSync(path.join(current, 'romfs')) || fs.existsSync(path.join(current, 'exefs'))) {
12921292
projectRoot = current;
12931293
foundRoot = true;
12941294
break;
@@ -1302,7 +1302,7 @@ export function registerArchiveFileCommands(context: vscode.ExtensionContext): v
13021302
}
13031303

13041304
if (!foundRoot) {
1305-
void vscode.window.showErrorMessage('Add to Option requires the project to have an "options" folder or ".tkproj" file.');
1305+
void vscode.window.showErrorMessage('Add to Option requires the project to be a valid mod folder (must contain romfs, exefs, or .tkproj).');
13061306
return;
13071307
}
13081308

src/archiveTree.ts

Lines changed: 174 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
6666

6767
private roots: vscode.Uri[] = [];
6868
private activeProjectRootUri: string | undefined;
69+
70+
private logicalRoots: Map<string, string> = new Map();
71+
private hasMultipleMods: Set<string> = new Set();
72+
73+
public get workspaceRoots(): vscode.Uri[] {
74+
return this.roots;
75+
}
6976

7077
private sortRoots(): void {
7178
this.roots.sort((a, b) =>
@@ -79,6 +86,17 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
7986
const stored = context.globalState.get<string[]>(STORAGE_KEY, []);
8087
this.roots = stored.map((fsPath) => toSarcUri(vscode.Uri.file(fsPath)));
8188
this.activeProjectRootUri = context.globalState.get<string>('totk-editor.activeProjectRoot');
89+
90+
const storedLogicalRoots = context.globalState.get<Record<string, string>>('totk-editor.logicalRoots', {});
91+
for (const [workspacePath, logicalPath] of Object.entries(storedLogicalRoots)) {
92+
this.logicalRoots.set(workspacePath, logicalPath);
93+
}
94+
95+
const storedMultipleMods = context.globalState.get<string[]>('totk-editor.hasMultipleMods', []);
96+
for (const workspacePath of storedMultipleMods) {
97+
this.hasMultipleMods.add(workspacePath);
98+
}
99+
82100
this.sortRoots();
83101
}
84102

@@ -114,9 +132,9 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
114132

115133
const activeTkmmOption = getActiveTkmmOption(this.context, projectRootPath);
116134

117-
return entries
135+
const children = await Promise.all(entries
118136
.sort(compareEntriesFoldersFirstKeepingArchivesMixed)
119-
.map(([name, fileType]) => {
137+
.map(async ([name, fileType]) => {
120138
const childUri = vscode.Uri.joinPath(element.resourceUri, name);
121139
const isDirectory = fileType === vscode.FileType.Directory || isArchiveFile(name);
122140

@@ -127,6 +145,29 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
127145
contextValue = 'tkmmOptionGroup';
128146
} else if (element.contextValue === 'tkmmOptionGroup' && isDirectory) {
129147
contextValue = 'tkmmOption';
148+
} else if (isDirectory && this.hasMultipleMods.has(projectRootPath) && contextValue === 'archiveDir') {
149+
// Check if this directory is a valid mod folder (has romfs/exefs/.tkproj)
150+
let isModFolder = false;
151+
try {
152+
const subEntries = await vscode.workspace.fs.readDirectory(childUri);
153+
for (const [subName, subType] of subEntries) {
154+
const lower = subName.toLowerCase();
155+
if (lower === 'romfs' || lower === 'exefs' || lower.endsWith('.tkproj')) {
156+
isModFolder = true;
157+
break;
158+
}
159+
}
160+
} catch {
161+
// Ignore
162+
}
163+
if (isModFolder) {
164+
const currentLogicalRoot = this.logicalRoots.get(projectRootPath);
165+
if (currentLogicalRoot && currentLogicalRoot === childUri.fsPath) {
166+
contextValue = 'archiveProjectDirActive';
167+
} else {
168+
contextValue = 'archiveProjectDir';
169+
}
170+
}
130171
}
131172

132173
let isActiveOption = false;
@@ -143,9 +184,10 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
143184
isDirectory
144185
? vscode.TreeItemCollapsibleState.Collapsed
145186
: vscode.TreeItemCollapsibleState.None,
146-
{ contextValue, isActive: isActiveOption },
187+
{ contextValue, isActive: isActiveOption || contextValue === 'archiveProjectDirActive' },
147188
);
148-
});
189+
}));
190+
return children;
149191
} catch (error) {
150192
const message = error instanceof Error ? error.message : String(error);
151193
void vscode.window.showErrorMessage(`TOTK Archives: ${message}`);
@@ -185,7 +227,7 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
185227
}
186228
}
187229

188-
addRoot(fileUri: vscode.Uri): void {
230+
async addRoot(fileUri: vscode.Uri): Promise<void> {
189231
const sarcUri = fileUri.scheme === 'sarc' ? fileUri : toSarcUri(fileUri);
190232
const key = sarcUri.fsPath;
191233
if (this.roots.some((root) => root.fsPath === key)) {
@@ -194,6 +236,37 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
194236
this.roots.push(sarcUri);
195237
this.sortRoots();
196238
void this.persistRoots();
239+
240+
const fileRootPath = sarcUri.fsPath;
241+
const validMods = await findValidModFolders(fileUri);
242+
if (validMods.length > 1) {
243+
this.hasMultipleMods.add(fileRootPath);
244+
const items = validMods.map(uri => ({
245+
label: path.basename(uri.fsPath) || uri.fsPath,
246+
description: path.relative(fileRootPath, uri.fsPath) || 'Root',
247+
uri
248+
}));
249+
const selection = await vscode.window.showQuickPick(items, {
250+
title: `Multiple mods found in ${path.basename(fileRootPath)}. Select the active project root.`,
251+
ignoreFocusOut: true,
252+
placeHolder: 'Select the active project root for this workspace'
253+
});
254+
255+
if (selection) {
256+
this.logicalRoots.set(fileRootPath, selection.uri.fsPath);
257+
} else {
258+
this.logicalRoots.set(fileRootPath, validMods[0]!.fsPath);
259+
}
260+
} else if (validMods.length === 1) {
261+
this.hasMultipleMods.delete(fileRootPath);
262+
this.logicalRoots.set(fileRootPath, validMods[0]!.fsPath);
263+
} else {
264+
this.hasMultipleMods.delete(fileRootPath);
265+
this.logicalRoots.delete(fileRootPath);
266+
}
267+
268+
await this.persistLogicalRoots();
269+
197270
this.onDidChangeTreeDataEmitter.fire(undefined);
198271
this.onDidChangeRootsEmitter.fire();
199272
void this.ensureRomfsFolder(fileUri);
@@ -206,7 +279,10 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
206279
return;
207280
}
208281
this.roots = next;
282+
this.logicalRoots.delete(key);
283+
this.hasMultipleMods.delete(key);
209284
void this.persistRoots();
285+
void this.persistLogicalRoots();
210286
this.onDidChangeTreeDataEmitter.fire(undefined);
211287
this.onDidChangeRootsEmitter.fire();
212288
}
@@ -216,24 +292,31 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
216292
}
217293

218294
getProjectRoots(): { fsPath: string; label: string }[] {
219-
return this.roots.map((root) => ({
220-
fsPath: root.fsPath,
221-
label: path.basename(root.fsPath),
222-
}));
295+
return this.roots.map((root) => {
296+
const logicalPath = this.logicalRoots.get(root.fsPath);
297+
const activePath = logicalPath || root.fsPath;
298+
return {
299+
fsPath: activePath,
300+
label: path.basename(activePath),
301+
};
302+
});
223303
}
224304

225305
setActiveProject(fsPath: string | undefined): void {
226-
this.activeProjectRootUri = fsPath;
227306
if (fsPath) {
228-
void this.context.globalState.update('totk-editor.activeProjectRoot', fsPath);
307+
const logicalPath = this.logicalRoots.get(fsPath);
308+
this.activeProjectRootUri = logicalPath || fsPath;
309+
void this.context.globalState.update('totk-editor.activeProjectRoot', this.activeProjectRootUri);
229310
} else {
311+
this.activeProjectRootUri = undefined;
230312
void this.context.globalState.update('totk-editor.activeProjectRoot', undefined);
231313
}
232314
this.onDidChangeTreeDataEmitter.fire(undefined);
233315
}
234316

235317
getActiveProject(): string | undefined {
236-
if (this.activeProjectRootUri && !this.roots.some((r) => r.fsPath === this.activeProjectRootUri)) {
318+
const currentProjectRoots = this.getProjectRoots().map(r => r.fsPath);
319+
if (this.activeProjectRootUri && !currentProjectRoots.includes(this.activeProjectRootUri)) {
237320
this.setActiveProject(undefined);
238321
}
239322
return this.activeProjectRootUri;
@@ -245,6 +328,21 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider<ArchiveTreeI
245328
this.roots.map((root) => root.fsPath),
246329
);
247330
}
331+
332+
private async persistLogicalRoots(): Promise<void> {
333+
const mapping: Record<string, string> = {};
334+
for (const [k, v] of this.logicalRoots.entries()) {
335+
mapping[k] = v;
336+
}
337+
await this.context.globalState.update('totk-editor.logicalRoots', mapping);
338+
await this.context.globalState.update('totk-editor.hasMultipleMods', Array.from(this.hasMultipleMods));
339+
}
340+
341+
public async setLogicalProjectRoot(workspacePath: string, logicalPath: string): Promise<void> {
342+
this.logicalRoots.set(workspacePath, logicalPath);
343+
await this.persistLogicalRoots();
344+
this.refresh();
345+
}
248346
}
249347

250348
function compareEntriesFoldersFirstKeepingArchivesMixed(
@@ -286,7 +384,7 @@ export function registerArchiveTree(context: vscode.ExtensionContext): ArchiveTr
286384
}),
287385
);
288386

289-
const addWorkspaceToArchives = (): void => {
387+
const addWorkspaceToArchives = async (): Promise<void> => {
290388
const folder = vscode.workspace.workspaceFolders?.[0];
291389
if (!folder) {
292390
void vscode.window.showWarningMessage(
@@ -300,7 +398,7 @@ export function registerArchiveTree(context: vscode.ExtensionContext): ArchiveTr
300398
);
301399
return;
302400
}
303-
provider.addRoot(folder.uri);
401+
await provider.addRoot(folder.uri);
304402
void focusArchiveSidebar();
305403
};
306404

@@ -314,7 +412,7 @@ export function registerArchiveTree(context: vscode.ExtensionContext): ArchiveTr
314412
});
315413
if (uris && uris.length > 0) {
316414
for (const uri of uris) {
317-
provider.addRoot(uri);
415+
await provider.addRoot(uri);
318416
}
319417
void focusArchiveSidebar();
320418
}
@@ -347,6 +445,16 @@ export function registerArchiveTree(context: vscode.ExtensionContext): ArchiveTr
347445
}
348446
}
349447
),
448+
vscode.commands.registerCommand(
449+
'totk-editor.setLogicalProjectRoot',
450+
async (item: ArchiveTreeItem | undefined) => {
451+
if (!item) return;
452+
const workspaceRoot = provider.workspaceRoots.find(r => item.resourceUri.fsPath.startsWith(r.fsPath));
453+
if (workspaceRoot) {
454+
await provider.setLogicalProjectRoot(workspaceRoot.fsPath, item.resourceUri.fsPath);
455+
}
456+
}
457+
),
350458
vscode.commands.registerCommand(
351459
'totk-editor.createOptionGroup',
352460
async (item: ArchiveTreeItem | undefined) => {
@@ -437,7 +545,7 @@ export async function migrateSarcWorkspaceFolders(
437545
for (let i = 0; i < folders.length; i++) {
438546
const folder = folders[i]!;
439547
if (folder.uri.scheme === 'sarc') {
440-
archiveTree.addRoot(vscode.Uri.file(folder.uri.fsPath));
548+
await archiveTree.addRoot(vscode.Uri.file(folder.uri.fsPath));
441549
toConvert.push({
442550
index: i,
443551
uri: vscode.Uri.file(folder.uri.fsPath),
@@ -450,15 +558,59 @@ export async function migrateSarcWorkspaceFolders(
450558
return;
451559
}
452560

453-
for (let i = toConvert.length - 1; i >= 0; i--) {
454-
const entry = toConvert[i]!;
455-
await vscode.workspace.updateWorkspaceFolders(entry.index, 1, {
456-
uri: entry.uri,
457-
name: entry.name,
458-
});
561+
for (let i = toConvert.length - 0; i >= 0; i--) {
562+
const entry = toConvert[i];
563+
if (entry) {
564+
await vscode.workspace.updateWorkspaceFolders(entry.index, 1, {
565+
uri: entry.uri,
566+
name: entry.name,
567+
});
568+
}
459569
}
460570

461571
void vscode.window.showInformationMessage(
462572
'TOTK Editor: Archive browsing moved to the **TOTK Archives** sidebar tab. Your workspace uses normal files again.',
463573
);
464574
}
575+
576+
async function findValidModFolders(rootUri: vscode.Uri, maxDepth: number = 3): Promise<vscode.Uri[]> {
577+
const validFolders: vscode.Uri[] = [];
578+
579+
async function scan(currentUri: vscode.Uri, depth: number) {
580+
if (depth > maxDepth) return;
581+
582+
try {
583+
const entries = await vscode.workspace.fs.readDirectory(currentUri);
584+
585+
let isModFolder = false;
586+
let hasSubDirs = false;
587+
const subdirs: string[] = [];
588+
589+
for (const [name, type] of entries) {
590+
const lowerName = name.toLowerCase();
591+
if (lowerName === 'romfs' || lowerName === 'exefs' || lowerName.endsWith('.tkproj')) {
592+
isModFolder = true;
593+
}
594+
if (type === vscode.FileType.Directory) {
595+
hasSubDirs = true;
596+
if (lowerName !== 'romfs' && lowerName !== 'exefs' && lowerName !== 'options') {
597+
subdirs.push(name);
598+
}
599+
}
600+
}
601+
602+
if (isModFolder) {
603+
validFolders.push(currentUri);
604+
} else if (hasSubDirs) {
605+
for (const subdir of subdirs) {
606+
await scan(vscode.Uri.joinPath(currentUri, subdir), depth + 1);
607+
}
608+
}
609+
} catch {
610+
// Ignore permissions/read errors
611+
}
612+
}
613+
614+
await scan(rootUri, 1);
615+
return validFolders;
616+
}

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1486,7 +1486,7 @@ export async function activate(context: vscode.ExtensionContext) {
14861486
});
14871487

14881488
if (fileUri?.[0]) {
1489-
archiveTree.addRoot(fileUri[0]);
1489+
await archiveTree.addRoot(fileUri[0]);
14901490
void focusArchiveSidebar();
14911491
}
14921492
});

0 commit comments

Comments
 (0)