From 1491a2d3e4d1b737130deb7c9c1ccc59fe20692d Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Fri, 6 Mar 2026 22:14:39 +0100 Subject: [PATCH 01/11] small cleanup --- packages/vscode-extension/package.json | 13 +- .../extraTablesTreeDataProvider.ts | 115 +++++++++--------- .../src/contributes/scriptTreeDataProvider.ts | 32 ++--- packages/vscode-extension/src/extension.ts | 1 - 4 files changed, 87 insertions(+), 74 deletions(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index a4c2828..75090f0 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -58,18 +58,27 @@ { "id": "ejfasting.view.scripts", "name": "Scripts", - "icon": "/resources/logo.svg" + "icon": "/resources/logo.svg", + "when": "superoffice.isAuthenticated" }, { "id": "ejfasting.view.extraTables", "name": "Extra Tables Explorer", - "icon": "/resources/logo.svg" + "icon": "/resources/logo.svg", + "when": "superoffice.isAuthenticated" + }, + { + "id": "ejfasting.view.empty", + "name": "Empty View", + "icon": "/resources/logo.svg", + "when": "!superoffice.isAuthenticated" } ] }, "viewsWelcome": [ { "view": "ejfasting.view.scripts", + "when": "!superoffice.isAuthenticated", "contents": "You are not logged in to SuperOffice [learn more](https://docs.superoffice.com/).\n[Login](command:ejfasting.authenticate)" } ], diff --git a/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts b/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts index 634fbec..e891a70 100644 --- a/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts +++ b/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts @@ -6,6 +6,8 @@ import { EventEmitter, Event, ExtensionContext, + window, + ProgressLocation, } from "vscode"; import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; import { IHttpService } from "../services/httpService"; @@ -57,66 +59,67 @@ export class ExtraTablesTreeDataProvider implements TreeDataProvider(); - archiveListItems.forEach((item) => { - if (item.columnData && item.columnData["extra_tables.table_name"]) { - const tableName = item.columnData["extra_tables.table_name"].displayValue as string; - if (!groupedTables.has(tableName)) { - groupedTables.set(tableName, []); + return await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Fetching extra tables...", + cancellable: false, + }, + async (_progress) => { + const archiveListItems = await this.httpService.getExtraTables(); + + // Group archiveListItems by property "extra_tables.table_name" + const groupedTables = new Map(); + archiveListItems.forEach((item) => { + if (item.columnData && item.columnData["extra_tables.table_name"]) { + const tableName = item.columnData["extra_tables.table_name"].displayValue as string; + if (!groupedTables.has(tableName)) { + groupedTables.set(tableName, []); + } + groupedTables.get(tableName)!.push(item); } - groupedTables.get(tableName)!.push(item); - } - }); - - const tableNodes: ExtraTablesNode[] = []; - groupedTables.forEach((tableItems, tableName) => { - const fieldNodes = tableItems.map((item) => { - if (!item.columnData) { - return new ExtraTablesNode("", undefined, new ThemeIcon("symbol-field")); - } - - const typeNode = new ExtraTablesNode( - "type: " + - (item.columnData["extra_tables.(extra_fields->extra_table).type"].displayValue || - ""), - undefined, - new ThemeIcon("symbol-property"), - ); - - const descriptionNode = new ExtraTablesNode( - "description: " + - (item.columnData["extra_tables.(extra_fields->extra_table).description"] - .displayValue || ""), - undefined, - new ThemeIcon("symbol-property"), - ); - - return new ExtraTablesNode( - item.columnData["extra_tables.(extra_fields->extra_table).field_name"].displayValue || - "", - [typeNode, descriptionNode], - new ThemeIcon("symbol-field"), - ); }); - // Create parent node for the table - const tableNode = new ExtraTablesNode(tableName, fieldNodes, new ThemeIcon("database")); - - tableNodes.push(tableNode); - }); + const tableNodes: ExtraTablesNode[] = []; + groupedTables.forEach((tableItems, tableName) => { + const fieldNodes = tableItems.map((item) => { + if (!item.columnData) { + return new ExtraTablesNode("", undefined, new ThemeIcon("symbol-field")); + } + + const typeNode = new ExtraTablesNode( + "type: " + + (item.columnData["extra_tables.(extra_fields->extra_table).type"].displayValue || + ""), + undefined, + new ThemeIcon("symbol-property"), + ); + + const descriptionNode = new ExtraTablesNode( + "description: " + + (item.columnData["extra_tables.(extra_fields->extra_table).description"] + .displayValue || ""), + undefined, + new ThemeIcon("symbol-property"), + ); + + return new ExtraTablesNode( + item.columnData["extra_tables.(extra_fields->extra_table).field_name"] + .displayValue || "", + [typeNode, descriptionNode], + new ThemeIcon("symbol-field"), + ); + }); + + // Create parent node for the table + const tableNode = new ExtraTablesNode(tableName, fieldNodes, new ThemeIcon("database")); + + tableNodes.push(tableNode); + }); - return tableNodes; - } catch (err) { - if (err instanceof Error) { - throw new Error(err.message); - } else { - throw new Error(String(err)); - } - } + return tableNodes; + }, + ); } return []; } diff --git a/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts b/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts index 047c4d8..a9e1893 100644 --- a/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts +++ b/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts @@ -8,6 +8,8 @@ import { EventEmitter, Event, ExtensionContext, + window, + ProgressLocation, } from "vscode"; import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; import { IHttpService } from "../services/httpService"; @@ -75,22 +77,22 @@ export class ScriptTreeDataProvider implements TreeDataProvider { return element.children || []; } const currentSession = this.authProvider.getCurrentSession(); - //Check if user is logged in if (currentSession) { - try { - const archiveListItems: ArchiveListItem[] = await this.httpService.fetchAllScriptData(); - const root: TreeDataItem = { label: "Root", children: [] }; - archiveListItems.forEach((listItem) => - this.addToTreeData(root, listItem.columnData?.["path"]?.displayValue ?? "", listItem), - ); - return root.children.map(this.convertTreeDataToNode); - } catch (err) { - if (err instanceof Error) { - throw new Error(err.message); - } else { - throw new Error(String(err)); - } - } + return await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Fetching scripts...", + cancellable: false, + }, + async (_progress) => { + const archiveListItems: ArchiveListItem[] = await this.httpService.fetchAllScriptData(); + const root: TreeDataItem = { label: "Root", children: [] }; + archiveListItems.forEach((listItem) => + this.addToTreeData(root, listItem.columnData?.["path"]?.displayValue ?? "", listItem), + ); + return root.children.map(this.convertTreeDataToNode); + }, + ); } return []; } diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 76042f3..9231381 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -34,7 +34,6 @@ export async function activate(context: ExtensionContext) { authProvider.onDidChangeSessions(() => { webApi = null; // Clear cached WebApi instance on session change - window.showInformationMessage("Authentication session changed. Refreshing views..."); webApi = setupWebApi(authProvider.getCurrentSession()!); scriptTreeDataProvider.refresh(); extraTablesTreeDataProvider.refresh(); From c0889bf74b633f8b216168d91c5419f99681332b Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:11:04 +0100 Subject: [PATCH 02/11] Added basic scm support --- .vscode/settings.json | 11 +- packages/vscode-extension/package.json | 34 +++ .../src/contributes/authenticationProvider.ts | 262 +----------------- .../src/contributes/commands.ts | 44 ++- .../src/contributes/sourceControl.ts | 23 ++ .../vscode-extension/src/contributes/views.ts | 6 +- packages/vscode-extension/src/data.ts | 4 - packages/vscode-extension/src/extension.ts | 29 +- .../src/providers/authenticationProvider.ts | 257 +++++++++++++++++ .../authenticationProvider.types.ts | 0 .../extraTablesTreeDataProvider.ts | 0 .../scmTextDocumentContentProvider.ts | 69 +++++ .../scriptTreeDataProvider.ts | 2 +- .../src/services/authenticationService.ts | 2 +- .../src/services/fileSystemService.ts | 7 +- .../src/services/fileSystemService.types.ts | 3 + .../src/services/scmService.ts | 253 +++++++++++++++++ .../src/services/scriptService.ts | 5 +- 18 files changed, 720 insertions(+), 291 deletions(-) create mode 100644 packages/vscode-extension/src/contributes/sourceControl.ts delete mode 100644 packages/vscode-extension/src/data.ts create mode 100644 packages/vscode-extension/src/providers/authenticationProvider.ts rename packages/vscode-extension/src/{contributes => providers}/authenticationProvider.types.ts (100%) rename packages/vscode-extension/src/{contributes => providers}/extraTablesTreeDataProvider.ts (100%) create mode 100644 packages/vscode-extension/src/providers/scmTextDocumentContentProvider.ts rename packages/vscode-extension/src/{contributes => providers}/scriptTreeDataProvider.ts (98%) create mode 100644 packages/vscode-extension/src/services/fileSystemService.types.ts create mode 100644 packages/vscode-extension/src/services/scmService.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index dcb84f7..7324252 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,14 @@ "oxc.typeAware": true, "oxc.fmt.configPath": ".oxfmtrc.json", "editor.formatOnSave": true, - "cSpell.words": ["isnan", "nonconstructor", "oxfmt", "oxlint", "tsgolint", "uninvoked"] + "cSpell.words": [ + "isnan", + "nonconstructor", + "oxfmt", + "oxfmtrc", + "oxlint", + "oxlintrc", + "tsgolint", + "uninvoked" + ] } diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 75090f0..cd60ec7 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -42,6 +42,16 @@ { "command": "ejfasting.script.downloadFolder", "title": "Download Script Folder" + }, + { + "command": "ejfasting.scm.discardChanges", + "title": "Discard Changes", + "icon": "$(discard)" + }, + { + "command": "ejfasting.scm.openFile", + "title": "Open File", + "icon": "$(go-to-file)" } ], "viewsContainers": { @@ -95,6 +105,30 @@ "group": "0_script", "when": "view == ejfasting.view.scripts && viewItem == folder" } + ], + "scm/resourceState/context": [ + { + "command": "ejfasting.scm.discardChanges", + "group": "inline", + "when": "scmProvider == superoffice" + }, + { + "command": "ejfasting.scm.openFile", + "group": "inline", + "when": "scmProvider == superoffice" + } + ], + "scm/resourceState/inline": [ + { + "command": "ejfasting.scm.discardChanges", + "group": "inline", + "when": "scmProvider == superoffice" + }, + { + "command": "ejfasting.scm.openFile", + "group": "inline", + "when": "scmProvider == superoffice" + } ] } }, diff --git a/packages/vscode-extension/src/contributes/authenticationProvider.ts b/packages/vscode-extension/src/contributes/authenticationProvider.ts index e8c1aa8..756119d 100644 --- a/packages/vscode-extension/src/contributes/authenticationProvider.ts +++ b/packages/vscode-extension/src/contributes/authenticationProvider.ts @@ -1,23 +1,12 @@ -import { - authentication, - AuthenticationProvider, - AuthenticationProviderAuthenticationSessionsChangeEvent, - commands, - Disposable, - EventEmitter, - ExtensionContext, - window, -} from "vscode"; -import { FileSystemService, IFileSystemService, SuoFile } from "../services/fileSystemService"; -import { AuthenticationService, IAuthenticationService } from "../services/authenticationService"; -import { v4 as uuid } from "uuid"; -import { Token } from "../services/authenticationService.types"; -import { SuperOfficeAuthenticationSession, UserClaims } from "./authenticationProvider.types"; +import { ExtensionContext } from "vscode"; +import { IFileSystemService } from "../services/fileSystemService"; +import { AuthenticationService } from "../services/authenticationService"; import { packagePublisher } from "../extension"; +import { SuperOfficeAuthenticationProvider } from "../providers/authenticationProvider"; export function registerAuthenticationProvider( context: ExtensionContext, - fileSystemService: FileSystemService, + fileSystemService: IFileSystemService, authenticationService: AuthenticationService, ) { const authProvider = new SuperOfficeAuthenticationProvider( @@ -29,244 +18,3 @@ export function registerAuthenticationProvider( context.subscriptions.push(authProvider); return authProvider; } - -export class SuperOfficeAuthenticationProvider implements AuthenticationProvider, Disposable { - private currentSession: SuperOfficeAuthenticationSession | undefined; - private _disposable: Disposable; - private _onDidChangeSessions = - new EventEmitter(); - - constructor( - private context: ExtensionContext, - private fileSystemService: IFileSystemService, - private authenticationService: IAuthenticationService, - private packagePublisher: string, - ) { - this._disposable = Disposable.from( - authentication.registerAuthenticationProvider( - this.packagePublisher.toLowerCase(), - this.packagePublisher, - this, - { - supportsMultipleAccounts: false, - }, - ), - ); - } - - get onDidChangeSessions() { - return this._onDidChangeSessions.event; - } - - /** - * Check if the session is expired based on the expiresAt property - * @param session the authentication session to check - * @returns true if the session is expired, false otherwise - */ - private isSessionExpired(session: SuperOfficeAuthenticationSession): boolean { - return session.expiresAt! < Date.now(); - } - - /** - * Find a session by its context identifier - * @returns the found session or null if not found - */ - private async retrieveSessionData(): Promise { - const sessionData = await this.context.secrets.get( - `${this.packagePublisher.toLowerCase()}.sessions`, - ); - return sessionData ? JSON.parse(sessionData) : null; - } - - /** - * Read .suo-file from the local workspace to get the context identifier for the session - * @returns the .suo-file or undefined if not found - */ - private async retrieveSuoFile(): Promise { - return await this.fileSystemService.readSuoFile(); - } - - /** - * Find a session by its context identifier - * @param sessions the list of sessions to search - * @param contextIdentifier the context identifier to match - * @returns the found session or undefined if not found - */ - private findSessionByIdentifier( - sessions: SuperOfficeAuthenticationSession[], - contextIdentifier: string, - ): SuperOfficeAuthenticationSession | undefined { - return sessions.find((obj) => obj.contextIdentifier === contextIdentifier); - } - /** - * Get the existing sessions - * @param scopes - * @returns - */ - public async getSessions(_scopes?: string[]): Promise { - try { - if (this.currentSession && !this.isSessionExpired(this.currentSession)) { - return [this.currentSession]; - } - - const suoFile = await this.retrieveSuoFile(); - if (!suoFile) return []; - - const sessions = await this.retrieveSessionData(); - if (!sessions) return []; - - const session = this.findSessionByIdentifier(sessions, suoFile.contextIdentifier); - if (!session || this.isSessionExpired(session)) { - if (session) await this.removeSession(session.id); - return []; - } - - await this.setSession(session); - return [session]; - } catch (error) { - console.error("Failed to retrieve or parse sessions:", error); - return []; - } - } - - /** - * Create a new auth session - * @param scopes - * @returns - */ - public async createSession(_scopes: string[]): Promise { - const environment = await this.selectEnvironment(); - - const tokenInformation = (await this.authenticationService.login(environment)) as Token; - const userClaims = this.authenticationService.getClaimsFromToken( - tokenInformation.id_token, - ) as UserClaims; - - const session = this.createSessionObject(userClaims, tokenInformation); - - await this.storeSessionData(session); - await this.setSession(session); - - return session; - } - - private async storeSessionData(session: SuperOfficeAuthenticationSession): Promise { - await this.context.secrets.store( - `${this.packagePublisher.toLowerCase()}.sessions`, - JSON.stringify([session]), - ); - await this.fileSystemService.writeSuoFile( - JSON.stringify({ contextIdentifier: session.contextIdentifier }), - ); - } - - /** - * Remove an existing session - * @param sessionId - */ - public async removeSession(sessionId: string): Promise { - const sessions = await this.getAllSessions(); - const [updatedSessions, removedSession] = this.removeSessionById(sessions, sessionId); - - await this.updateStoredSessions(updatedSessions); - this.fireSessionChangeEvent(removedSession); - - this.currentSession = undefined; - await this.updateContextKey(false); - } - - private async getAllSessions(): Promise { - const allSessions = await this.context.secrets.get( - `${this.packagePublisher.toLowerCase()}.sessions`, - ); - return allSessions ? JSON.parse(allSessions) : []; - } - - private removeSessionById( - sessions: SuperOfficeAuthenticationSession[], - sessionId: string, - ): [SuperOfficeAuthenticationSession[], SuperOfficeAuthenticationSession | undefined] { - const sessionIdx = sessions.findIndex((s) => s.id === sessionId); - const removedSession = sessionIdx !== -1 ? sessions.splice(sessionIdx, 1)[0] : undefined; - return [sessions, removedSession]; - } - - private async updateStoredSessions(sessions: SuperOfficeAuthenticationSession[]): Promise { - await this.context.secrets.store( - `${this.packagePublisher.toLowerCase()}.sessions`, - JSON.stringify(sessions), - ); - } - - /** - * Set the current session and fire the onDidChangeSessions event - * @param session the session to set as current - */ - public async setSession(session: SuperOfficeAuthenticationSession): Promise { - this.currentSession = session; - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - await this.updateContextKey(true); - } - - /** - * Update the context key to control the visibility of UI elements based on authentication state - * @param isAuthenticated whether the user is authenticated - */ - private async updateContextKey(isAuthenticated: boolean): Promise { - await commands.executeCommand("setContext", "superoffice.isAuthenticated", isAuthenticated); - } - - private fireSessionChangeEvent(removedSession?: SuperOfficeAuthenticationSession): void { - if (removedSession) { - this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); - } - } - - /** - * Prompt the user to select an environment - * @returns the selected environment - */ - private async selectEnvironment(): Promise { - const environment = await window.showQuickPick(["sod", "online"], { - placeHolder: "Select an environment", - canPickMany: false, - }); - - if (!environment) { - throw new Error("Environment selection was canceled by the user."); - } - - return environment; - } - - private createSessionObject( - claims: UserClaims, - tokenInformation: Token, - ): SuperOfficeAuthenticationSession { - return { - id: uuid(), - contextIdentifier: claims["http://schemes.superoffice.net/identity/ctx"], - accessToken: tokenInformation.access_token!, - refreshToken: tokenInformation.refresh_token, - webApiUri: claims["http://schemes.superoffice.net/identity/webapi_url"], - expiresAt: Date.now() + 3600 * 1000, - claims: claims, - account: { - label: claims["http://schemes.superoffice.net/identity/ctx"], - id: claims["http://schemes.superoffice.net/identity/ctx"], - }, - scopes: [], - }; - } - - public getCurrentSession(): SuperOfficeAuthenticationSession | undefined { - return this.currentSession; - } - - /** - * Dispose the registered services - */ - public async dispose() { - this._disposable.dispose(); - } -} diff --git a/packages/vscode-extension/src/contributes/commands.ts b/packages/vscode-extension/src/contributes/commands.ts index 701b7a2..971f481 100644 --- a/packages/vscode-extension/src/contributes/commands.ts +++ b/packages/vscode-extension/src/contributes/commands.ts @@ -1,12 +1,11 @@ -import { authentication, commands, window } from "vscode"; -import { versionStr, max } from "../data"; -import { randomInt } from "node:crypto"; +import { authentication, commands, window, SourceControlResourceState } from "vscode"; import { packagePublisher } from "../extension"; -import { SuperOfficeAuthenticationSession } from "./authenticationProvider.types"; +import { SuperOfficeAuthenticationSession } from "../providers/authenticationProvider.types"; import { ArchiveListItem } from "@superoffice/webapi"; import { HttpService } from "../services/httpService"; -import { Node } from "./scriptTreeDataProvider"; +import { Node } from "../providers/scriptTreeDataProvider"; import { ScriptService } from "../services/scriptService"; +import { IScmService } from "../services/scmService"; export enum Commands { ShowGreeting = "ejfasting.showGreeting", @@ -14,14 +13,15 @@ export enum Commands { ViewScriptDetails = "ejfasting.script.viewDetails", DownloadScript = "ejfasting.script.download", DownloadScriptFolder = "ejfasting.script.downloadFolder", + DiscardChanges = "ejfasting.scm.discardChanges", + OpenFile = "ejfasting.scm.openFile", } -export async function registerCommands(httpService: HttpService, scriptService: ScriptService) { - commands.registerCommand(Commands.ShowGreeting, () => { - const temp = `[${randomInt(max)}] Hello World to ${versionStr}! I am here! ddd`; - window.showInformationMessage(temp); - }); - +export async function registerCommands( + httpService: HttpService, + scriptService: ScriptService, + scriptSourceControlService: IScmService, +) { commands.registerCommand(Commands.Authenticate, async () => { const session = (await authentication.getSession(`${packagePublisher.toLowerCase()}`, [], { createIfNone: true, @@ -43,4 +43,26 @@ export async function registerCommands(httpService: HttpService, scriptService: `Downloaded ${results.downloadedCount} scripts with total size ${results.totalSize}. ${results.errors.length} errors occurred.`, ); }); + + commands.registerCommand( + Commands.DiscardChanges, + async (resource: SourceControlResourceState) => { + const filename = resource.resourceUri.path.split("/").pop() ?? resource.resourceUri.path; + const confirmed = await window.showWarningMessage( + `Discard changes to ${filename}? This will restore the version last downloaded from SuperOffice (might not be latest!).`, + { modal: true }, + "Discard", + ); + + if (confirmed !== "Discard") { + return; + } + + await scriptSourceControlService.discardChanges(resource.resourceUri); + }, + ); + + commands.registerCommand(Commands.OpenFile, async (resource: SourceControlResourceState) => { + await scriptSourceControlService.openFile(resource.resourceUri); + }); } diff --git a/packages/vscode-extension/src/contributes/sourceControl.ts b/packages/vscode-extension/src/contributes/sourceControl.ts new file mode 100644 index 0000000..acb28d2 --- /dev/null +++ b/packages/vscode-extension/src/contributes/sourceControl.ts @@ -0,0 +1,23 @@ +import { ExtensionContext, workspace } from "vscode"; +import { ScmTextDocumentContentProvider } from "../providers/scmTextDocumentContentProvider"; +import { ScmService } from "../services/scmService"; +import { IFileSystemHandler } from "../handlers/fileSystemHandler"; + +export const ORIGINAL_SCHEME = "superoffice-original"; + +export async function registerSourceControl( + context: ExtensionContext, + fileSystemHandler: IFileSystemHandler, +): Promise { + const scmTextDocumentContentProvider = new ScmTextDocumentContentProvider(); + + context.subscriptions.push( + workspace.registerTextDocumentContentProvider(ORIGINAL_SCHEME, scmTextDocumentContentProvider), + ); + + const scmService = new ScmService(fileSystemHandler, scmTextDocumentContentProvider); + context.subscriptions.push(scmService); + await scmService.initialize(); + + return scmService; +} diff --git a/packages/vscode-extension/src/contributes/views.ts b/packages/vscode-extension/src/contributes/views.ts index 09880ea..0fc0a75 100644 --- a/packages/vscode-extension/src/contributes/views.ts +++ b/packages/vscode-extension/src/contributes/views.ts @@ -1,8 +1,8 @@ import { ExtensionContext, window } from "vscode"; -import { ScriptTreeDataProvider } from "./scriptTreeDataProvider"; -import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; +import { ScriptTreeDataProvider } from "../providers/scriptTreeDataProvider"; +import { SuperOfficeAuthenticationProvider } from "../providers/authenticationProvider"; import { HttpService } from "../services/httpService"; -import { ExtraTablesTreeDataProvider } from "./extraTablesTreeDataProvider"; +import { ExtraTablesTreeDataProvider } from "../providers/extraTablesTreeDataProvider"; export enum Views { ScriptExplorer = "ejfasting.view.scripts", diff --git a/packages/vscode-extension/src/data.ts b/packages/vscode-extension/src/data.ts deleted file mode 100644 index 2aded29..0000000 --- a/packages/vscode-extension/src/data.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const max = 423; -import * as vscode from "vscode"; - -export const versionStr = `${vscode.env.appName}@${vscode.version}`; diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 9231381..ad4cb42 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -1,4 +1,4 @@ -import { ExtensionContext, window } from "vscode"; +import { ExtensionContext } from "vscode"; import { registerCommands } from "./contributes/commands"; import { registerAuthenticationProvider } from "./contributes/authenticationProvider"; import { FileSystemService } from "./services/fileSystemService"; @@ -8,8 +8,9 @@ import { AuthenticationService } from "./services/authenticationService"; import { HttpService } from "./services/httpService"; import { registerViews } from "./contributes/views"; import { WebApi } from "@superoffice/webapi"; -import { SuperOfficeAuthenticationSession } from "./contributes/authenticationProvider.types"; +import { SuperOfficeAuthenticationSession } from "./providers/authenticationProvider.types"; import { ScriptService } from "./services/scriptService"; +import { registerSourceControl } from "./contributes/sourceControl"; export let packagePublisher: string = ""; export let webApi: WebApi | null = null; @@ -17,7 +18,8 @@ export let webApi: WebApi | null = null; export async function activate(context: ExtensionContext) { packagePublisher = getPackagePublisher(context); - const { fileSystemService, authenticationService, httpService, scriptService } = setupServices(); + const { fileSystemService, authenticationService, httpService, scriptService, scmService } = + await setupServices(context); const authProvider = registerAuthenticationProvider( context, @@ -25,13 +27,16 @@ export async function activate(context: ExtensionContext) { authenticationService, ); - await registerCommands(httpService, scriptService); + await registerCommands(httpService, scriptService, scmService); const { scriptTreeDataProvider, extraTablesTreeDataProvider } = registerViews( context, authProvider, httpService, ); + /** + * This is called whenever the authentication session changes. It clears the cached WebApi instance and sets up a new one with the current session. It also refreshes the script and extra tables tree data providers to reflect any changes. + */ authProvider.onDidChangeSessions(() => { webApi = null; // Clear cached WebApi instance on session change webApi = setupWebApi(authProvider.getCurrentSession()!); @@ -49,11 +54,21 @@ function setupWebApi(session: SuperOfficeAuthenticationSession): WebApi { return webApi; } -function setupServices() { +/** + * Initializes the various services used by the extension, such as file system, authentication, HTTP, script management, and source control. This is called during extension activation to set up the necessary infrastructure for the extension's functionality. + */ +async function setupServices(context: ExtensionContext) { const fileSystemHandler = new FileSystemHandler(); const fileSystemService = new FileSystemService(fileSystemHandler); const authenticationService = new AuthenticationService(); const httpService = new HttpService(); - const scriptService = new ScriptService(httpService, fileSystemService); - return { fileSystemService, authenticationService, httpService, scriptService }; + const scmService = await registerSourceControl(context, fileSystemHandler); + const scriptService = new ScriptService(httpService, fileSystemService, scmService); + return { + fileSystemService, + authenticationService, + httpService, + scriptService, + scmService, + }; } diff --git a/packages/vscode-extension/src/providers/authenticationProvider.ts b/packages/vscode-extension/src/providers/authenticationProvider.ts new file mode 100644 index 0000000..f795330 --- /dev/null +++ b/packages/vscode-extension/src/providers/authenticationProvider.ts @@ -0,0 +1,257 @@ +import { + authentication, + AuthenticationProvider, + AuthenticationProviderAuthenticationSessionsChangeEvent, + commands, + Disposable, + EventEmitter, + ExtensionContext, + window, +} from "vscode"; +import { IFileSystemService } from "../services/fileSystemService"; +import { IAuthenticationService } from "../services/authenticationService"; +import { v4 as uuid } from "uuid"; +import { Token } from "../services/authenticationService.types"; +import { SuperOfficeAuthenticationSession, UserClaims } from "./authenticationProvider.types"; +import { SuoFile } from "../services/fileSystemService.types"; + +export class SuperOfficeAuthenticationProvider implements AuthenticationProvider, Disposable { + private currentSession: SuperOfficeAuthenticationSession | undefined; + private _disposable: Disposable; + private _onDidChangeSessions = + new EventEmitter(); + + constructor( + private context: ExtensionContext, + private fileSystemService: IFileSystemService, + private authenticationService: IAuthenticationService, + private packagePublisher: string, + ) { + this._disposable = Disposable.from( + authentication.registerAuthenticationProvider( + this.packagePublisher.toLowerCase(), + this.packagePublisher, + this, + { + supportsMultipleAccounts: false, + }, + ), + ); + } + + get onDidChangeSessions() { + return this._onDidChangeSessions.event; + } + + /** + * Check if the session is expired based on the expiresAt property + * @param session the authentication session to check + * @returns true if the session is expired, false otherwise + */ + private isSessionExpired(session: SuperOfficeAuthenticationSession): boolean { + return session.expiresAt! < Date.now(); + } + + /** + * Find a session by its context identifier + * @returns the found session or null if not found + */ + private async retrieveSessionData(): Promise { + const sessionData = await this.context.secrets.get( + `${this.packagePublisher.toLowerCase()}.sessions`, + ); + return sessionData ? JSON.parse(sessionData) : null; + } + + /** + * Read .suo-file from the local workspace to get the context identifier for the session + * @returns the .suo-file or undefined if not found + */ + private async retrieveSuoFile(): Promise { + return await this.fileSystemService.readSuoFile(); + } + + /** + * Find a session by its context identifier + * @param sessions the list of sessions to search + * @param contextIdentifier the context identifier to match + * @returns the found session or undefined if not found + */ + private findSessionByIdentifier( + sessions: SuperOfficeAuthenticationSession[], + contextIdentifier: string, + ): SuperOfficeAuthenticationSession | undefined { + return sessions.find((obj) => obj.contextIdentifier === contextIdentifier); + } + /** + * Get the existing sessions + * @param scopes + * @returns + */ + public async getSessions(_scopes?: string[]): Promise { + try { + if (this.currentSession && !this.isSessionExpired(this.currentSession)) { + return [this.currentSession]; + } + + const suoFile = await this.retrieveSuoFile(); + if (!suoFile) return []; + + const sessions = await this.retrieveSessionData(); + if (!sessions) return []; + + const session = this.findSessionByIdentifier(sessions, suoFile.contextIdentifier); + if (!session || this.isSessionExpired(session)) { + if (session) await this.removeSession(session.id); + return []; + } + + await this.setSession(session); + return [session]; + } catch (error) { + console.error("Failed to retrieve or parse sessions:", error); + return []; + } + } + + /** + * Create a new auth session + * @param scopes + * @returns + */ + public async createSession(_scopes: string[]): Promise { + const environment = await this.selectEnvironment(); + + const tokenInformation = (await this.authenticationService.login(environment)) as Token; + const userClaims = this.authenticationService.getClaimsFromToken( + tokenInformation.id_token, + ) as UserClaims; + + const session = this.createSessionObject(userClaims, tokenInformation); + + await this.storeSessionData(session); + await this.setSession(session); + + return session; + } + + private async storeSessionData(session: SuperOfficeAuthenticationSession): Promise { + await this.context.secrets.store( + `${this.packagePublisher.toLowerCase()}.sessions`, + JSON.stringify([session]), + ); + await this.fileSystemService.writeSuoFile( + JSON.stringify({ contextIdentifier: session.contextIdentifier }), + ); + } + + /** + * Remove an existing session + * @param sessionId + */ + public async removeSession(sessionId: string): Promise { + const sessions = await this.getAllSessions(); + const [updatedSessions, removedSession] = this.removeSessionById(sessions, sessionId); + + await this.updateStoredSessions(updatedSessions); + this.fireSessionChangeEvent(removedSession); + + this.currentSession = undefined; + await this.updateContextKey(false); + } + + private async getAllSessions(): Promise { + const allSessions = await this.context.secrets.get( + `${this.packagePublisher.toLowerCase()}.sessions`, + ); + return allSessions ? JSON.parse(allSessions) : []; + } + + private removeSessionById( + sessions: SuperOfficeAuthenticationSession[], + sessionId: string, + ): [SuperOfficeAuthenticationSession[], SuperOfficeAuthenticationSession | undefined] { + const sessionIdx = sessions.findIndex((s) => s.id === sessionId); + const removedSession = sessionIdx !== -1 ? sessions.splice(sessionIdx, 1)[0] : undefined; + return [sessions, removedSession]; + } + + private async updateStoredSessions(sessions: SuperOfficeAuthenticationSession[]): Promise { + await this.context.secrets.store( + `${this.packagePublisher.toLowerCase()}.sessions`, + JSON.stringify(sessions), + ); + } + + /** + * Set the current session and fire the onDidChangeSessions event + * @param session the session to set as current + */ + public async setSession(session: SuperOfficeAuthenticationSession): Promise { + this.currentSession = session; + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + await this.updateContextKey(true); + } + + /** + * Update the context key to control the visibility of UI elements based on authentication state + * @param isAuthenticated whether the user is authenticated + */ + private async updateContextKey(isAuthenticated: boolean): Promise { + await commands.executeCommand("setContext", "superoffice.isAuthenticated", isAuthenticated); + } + + private fireSessionChangeEvent(removedSession?: SuperOfficeAuthenticationSession): void { + if (removedSession) { + this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); + } + } + + /** + * Prompt the user to select an environment + * @returns the selected environment + */ + private async selectEnvironment(): Promise { + const environment = await window.showQuickPick(["sod", "online"], { + placeHolder: "Select an environment", + canPickMany: false, + }); + + if (!environment) { + throw new Error("Environment selection was canceled by the user."); + } + + return environment; + } + + private createSessionObject( + claims: UserClaims, + tokenInformation: Token, + ): SuperOfficeAuthenticationSession { + return { + id: uuid(), + contextIdentifier: claims["http://schemes.superoffice.net/identity/ctx"], + accessToken: tokenInformation.access_token!, + refreshToken: tokenInformation.refresh_token, + webApiUri: claims["http://schemes.superoffice.net/identity/webapi_url"], + expiresAt: Date.now() + 3600 * 1000, + claims: claims, + account: { + label: claims["http://schemes.superoffice.net/identity/ctx"], + id: claims["http://schemes.superoffice.net/identity/ctx"], + }, + scopes: [], + }; + } + + public getCurrentSession(): SuperOfficeAuthenticationSession | undefined { + return this.currentSession; + } + + /** + * Dispose the registered services + */ + public async dispose() { + this._disposable.dispose(); + } +} diff --git a/packages/vscode-extension/src/contributes/authenticationProvider.types.ts b/packages/vscode-extension/src/providers/authenticationProvider.types.ts similarity index 100% rename from packages/vscode-extension/src/contributes/authenticationProvider.types.ts rename to packages/vscode-extension/src/providers/authenticationProvider.types.ts diff --git a/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts b/packages/vscode-extension/src/providers/extraTablesTreeDataProvider.ts similarity index 100% rename from packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts rename to packages/vscode-extension/src/providers/extraTablesTreeDataProvider.ts diff --git a/packages/vscode-extension/src/providers/scmTextDocumentContentProvider.ts b/packages/vscode-extension/src/providers/scmTextDocumentContentProvider.ts new file mode 100644 index 0000000..9e86cda --- /dev/null +++ b/packages/vscode-extension/src/providers/scmTextDocumentContentProvider.ts @@ -0,0 +1,69 @@ +import { CancellationToken, EventEmitter, TextDocumentContentProvider, Uri } from "vscode"; +import { ORIGINAL_SCHEME } from "../contributes/sourceControl"; + +/** + * Provides stored "original" (server) content for downloaded scripts. + * Registered under the `superoffice-original://` URI scheme so that VS Code + * can diff the server version against the locally edited file. + */ +export class ScmTextDocumentContentProvider implements TextDocumentContentProvider { + private readonly _onDidChange = new EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + /** Maps stringified URI path → original source code from the server. */ + private readonly _contents = new Map(); + + /** + * Stores the original server content for a given local file URI. + * The local URI is converted to the virtual `superoffice-original://` scheme. + */ + public store(localUri: Uri, originalContent: string): void { + const key = this.toKey(localUri); + this._contents.set(key, originalContent); + this._onDidChange.fire(this.toOriginalUri(localUri)); + } + + /** + * Removes the stored original for a given local file URI. + */ + public remove(localUri: Uri): void { + this._contents.delete(this.toKey(localUri)); + } + + /** + * Returns the virtual `superoffice-original://` URI for a local file URI. + */ + public toOriginalUri(localUri: Uri): Uri { + return localUri.with({ scheme: ORIGINAL_SCHEME }); + } + + /** + * Returns all local URIs that currently have stored originals. + */ + public getTrackedUris(): Uri[] { + return Array.from(this._contents.keys()).map((key) => Uri.parse(key)); + } + + /** + * Returns the stored original content for a given local file URI, or undefined if not tracked. + */ + public getOriginalContent(localUri: Uri): string | undefined { + return this._contents.get(this.toKey(localUri)); + } + + /** Called by VS Code when content for a `superoffice-original://` URI is needed. */ + public provideTextDocumentContent(uri: Uri, _token: CancellationToken): string { + // Strip the scheme back to the local path key + const localUri = uri.with({ scheme: "file" }); + return this._contents.get(this.toKey(localUri)) ?? ""; + } + + public dispose(): void { + this._onDidChange.dispose(); + } + + private toKey(localUri: Uri): string { + // Normalise to file:// URI string so paths are consistent across platforms + return localUri.with({ scheme: "file" }).toString(); + } +} diff --git a/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts b/packages/vscode-extension/src/providers/scriptTreeDataProvider.ts similarity index 98% rename from packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts rename to packages/vscode-extension/src/providers/scriptTreeDataProvider.ts index a9e1893..d0c2b94 100644 --- a/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts +++ b/packages/vscode-extension/src/providers/scriptTreeDataProvider.ts @@ -14,7 +14,7 @@ import { import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; import { IHttpService } from "../services/httpService"; import { ArchiveListItem } from "@superoffice/webapi"; -import { Commands } from "./commands"; +import { Commands } from "../contributes/commands"; interface TreeDataItem { label: string; diff --git a/packages/vscode-extension/src/services/authenticationService.ts b/packages/vscode-extension/src/services/authenticationService.ts index ddeaf06..49842fe 100644 --- a/packages/vscode-extension/src/services/authenticationService.ts +++ b/packages/vscode-extension/src/services/authenticationService.ts @@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid"; import * as crypto from "crypto"; import { PromiseAdapter, promiseFromEvent } from "../utils"; import { Token } from "./authenticationService.types"; -import { UserClaims } from "../contributes/authenticationProvider.types"; +import { UserClaims } from "../providers/authenticationProvider.types"; const CLIENT_ID = `1a5764a8090f136cc9d30f381626d5fa`; diff --git a/packages/vscode-extension/src/services/fileSystemService.ts b/packages/vscode-extension/src/services/fileSystemService.ts index b2a8e0d..6e62ce5 100644 --- a/packages/vscode-extension/src/services/fileSystemService.ts +++ b/packages/vscode-extension/src/services/fileSystemService.ts @@ -2,11 +2,8 @@ import { Uri, workspace, window, FileSystemError } from "vscode"; import { posix } from "path"; import { IFileSystemHandler } from "../handlers/fileSystemHandler"; import { CRMScriptEntity, ScriptType } from "@superoffice/webapi"; -import { Node } from "../contributes/scriptTreeDataProvider"; - -export type SuoFile = { - contextIdentifier: string; -}; +import { Node } from "../providers/scriptTreeDataProvider"; +import { SuoFile } from "./fileSystemService.types"; export interface IFileSystemService { readSuoFile(): Promise; diff --git a/packages/vscode-extension/src/services/fileSystemService.types.ts b/packages/vscode-extension/src/services/fileSystemService.types.ts new file mode 100644 index 0000000..daa432f --- /dev/null +++ b/packages/vscode-extension/src/services/fileSystemService.types.ts @@ -0,0 +1,3 @@ +export type SuoFile = { + contextIdentifier: string; +}; diff --git a/packages/vscode-extension/src/services/scmService.ts b/packages/vscode-extension/src/services/scmService.ts new file mode 100644 index 0000000..398b7f1 --- /dev/null +++ b/packages/vscode-extension/src/services/scmService.ts @@ -0,0 +1,253 @@ +import { createHash } from "crypto"; +import { + Disposable, + FileSystemWatcher, + scm, + SourceControl, + SourceControlResourceGroup, + SourceControlResourceState, + Uri, + window, + workspace, +} from "vscode"; +import { IFileSystemHandler } from "../handlers/fileSystemHandler"; +import { ScmTextDocumentContentProvider } from "../providers/scmTextDocumentContentProvider"; + +export interface IScmService { + initialize(): Promise; + trackScript(localUri: Uri, originalContent: string): Promise; + discardChanges(localUri: Uri): Promise; + openFile(localUri: Uri): Promise; + dispose(): void; +} + +/** + * Registers a custom Source Control provider for SuperOffice scripts. + * Surfaces locally modified scripts in VS Code's Source Control panel (SCM view). + * Clicking a changed file opens a diff between the local version and the server + * version captured at download time. + * + * Persistence mirrors Git's object store: + * .superoffice/objects/ — deduplicated content blobs + * .superoffice/index.json — maps workspace-relative path → sha1 + */ +export class ScmService implements IScmService { + private readonly _sourceControl: SourceControl; + private readonly _changesGroup: SourceControlResourceGroup; + private readonly _watcher: FileSystemWatcher; + private readonly _disposables: Disposable[] = []; + private readonly _workspaceRoot: Uri; + private _debounceTimer: ReturnType | undefined; + + /** URI of the index file that maps workspace-relative paths to SHA1 hashes. */ + private get _indexUri(): Uri { + return this._workspaceRoot.with({ + path: `${this._workspaceRoot.path}/.superoffice/index.json`, + }); + } + + /** URI of a content object identified by its SHA1 hash. */ + private _objectUri(sha1: string): Uri { + return this._workspaceRoot.with({ + path: `${this._workspaceRoot.path}/.superoffice/objects/${sha1}`, + }); + } + + constructor( + private readonly fileSystemHandler: IFileSystemHandler, + private readonly scmTextDocumentContentProvider: ScmTextDocumentContentProvider, + ) { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri; + + if (!workspaceRoot) { + throw new Error("No workspace folder is open."); + } + + this._workspaceRoot = workspaceRoot; + + this._sourceControl = scm.createSourceControl( + "superoffice", + "SuperOffice Scripts", + workspaceRoot, + ); + + this._changesGroup = this._sourceControl.createResourceGroup("changes", "Changes"); + this._changesGroup.hideWhenEmpty = true; + + // Watch for changes to script files in the workspace + this._watcher = workspace.createFileSystemWatcher("**/*.{tsfso,crmscript}"); + this._disposables.push( + this._watcher.onDidChange(() => this._scheduleRefresh()), + this._watcher.onDidCreate(() => this._scheduleRefresh()), + this._watcher.onDidDelete(() => this._scheduleRefresh()), + this._watcher, + ); + } + + /** + * Restores tracked originals from disk on extension activation. + * Reads index.json to find all tracked paths, resolves the SHA1 object for each, + * and repopulates the in-memory OriginalContentProvider map. + */ + public async initialize(): Promise { + const index = await this._readIndex(); + const entries = Object.entries(index); + + if (entries.length === 0) { + return; + } + + for (const [relativePath, sha1] of entries) { + const content = await this.fileSystemHandler.readFile(this._objectUri(sha1)); + if (content !== undefined) { + const localUri = this._workspaceRoot.with({ + path: `${this._workspaceRoot.path}${relativePath}`, + }); + this.scmTextDocumentContentProvider.store(localUri, content); + } + } + + await this._refresh(); + } + + /** + * Stores the server-side original for a script both in memory and on disk. + * + * On disk this writes two things (mirroring Git): + * 1. A content-addressable object file at .superoffice/objects/ + * (skipped if an identical object already exists — free deduplication). + * 2. An updated .superoffice/index.json entry mapping the file's + * workspace-relative path to its SHA1 hash. + * + * Called immediately after a script is downloaded and written to disk. + */ + public async trackScript(localUri: Uri, originalContent: string): Promise { + this.scmTextDocumentContentProvider.store(localUri, originalContent); + await this._persistOriginal(localUri, originalContent); + await this._refresh(); + } + + /** + * Overwrites the local file with the original server content captured at download + * time, discarding any local edits — analogous to `git checkout -- `. + */ + public async discardChanges(localUri: Uri): Promise { + const original = this.scmTextDocumentContentProvider.getOriginalContent(localUri); + if (original === undefined) { + return; + } + await this.fileSystemHandler.writeFile(localUri, original); + await this._refresh(); + } + + /** + * Opens a local file in the editor. + */ + public async openFile(localUri: Uri): Promise { + const document = await workspace.openTextDocument(localUri); + await window.showTextDocument(document); + } + + /** + * Cleans up resources when the extension is deactivated. + */ + public dispose(): void { + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + this._sourceControl.dispose(); + this._changesGroup.dispose(); + this._disposables.forEach((d) => d.dispose()); + } + + private _scheduleRefresh(): void { + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + this._debounceTimer = setTimeout(() => void this._refresh(), 300); + } + + /** Computes the SHA1 hex digest of a string, matching Git's blob hashing. */ + private _computeSha1(content: string): string { + return createHash("sha1").update(content, "utf8").digest("hex"); + } + + /** Reads and parses index.json, returning an empty object if it doesn't exist yet. */ + private async _readIndex(): Promise> { + const raw = await this.fileSystemHandler.readFile(this._indexUri); + if (!raw) { + return {}; + } + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } + } + + /** Persists the updated index to .superoffice/index.json. */ + private async _writeIndex(index: Record): Promise { + const dirUri = this._indexUri.with({ + path: this._indexUri.path.replace(/\/[^/]+$/, ""), + }); + await workspace.fs.createDirectory(dirUri); + await this.fileSystemHandler.writeFile(this._indexUri, JSON.stringify(index, null, 2)); + } + + /** + * Persists a script's original content using a Git-style content-addressable store. + * The object file is only written when the SHA1 is not yet present (deduplication). + */ + private async _persistOriginal(localUri: Uri, content: string): Promise { + const sha1 = this._computeSha1(content); + const objectUri = this._objectUri(sha1); + + if (!(await this.fileSystemHandler.exists(objectUri))) { + const objectsDir = objectUri.with({ path: objectUri.path.replace(/\/[^/]+$/, "") }); + await workspace.fs.createDirectory(objectsDir); + await this.fileSystemHandler.writeFile(objectUri, content); + } + + const relativePath = localUri.path.slice(this._workspaceRoot.path.length); + const index = await this._readIndex(); + index[relativePath] = sha1; + await this._writeIndex(index); + } + + private async _refresh(): Promise { + const trackedUris = this.scmTextDocumentContentProvider.getTrackedUris(); + const resourceStates: SourceControlResourceState[] = []; + + for (const localUri of trackedUris) { + const originalContent = this.scmTextDocumentContentProvider.getOriginalContent(localUri); + const localContent = await this.fileSystemHandler.readFile(localUri); + + // Only surface the file if its local content differs from the server original + if (localContent === undefined || localContent === originalContent) { + continue; + } + + const originalUri = this.scmTextDocumentContentProvider.toOriginalUri(localUri); + + resourceStates.push({ + resourceUri: localUri, + command: { + title: "Show Changes", + command: "vscode.diff", + arguments: [originalUri, localUri, `${this._getFilename(localUri)} (SuperOffice)`], + }, + decorations: { + strikeThrough: false, + tooltip: "Modified locally", + }, + }); + } + + this._changesGroup.resourceStates = resourceStates; + this._sourceControl.count = resourceStates.length; + } + + private _getFilename(uri: Uri): string { + return uri.path.split("/").pop() ?? uri.path; + } +} diff --git a/packages/vscode-extension/src/services/scriptService.ts b/packages/vscode-extension/src/services/scriptService.ts index 427867e..e2db057 100644 --- a/packages/vscode-extension/src/services/scriptService.ts +++ b/packages/vscode-extension/src/services/scriptService.ts @@ -2,8 +2,9 @@ import { ArchiveListItem } from "@superoffice/webapi"; import { TextDocument, Uri, window, workspace, WorkspaceEdit, Position } from "vscode"; import { IFileSystemService } from "./fileSystemService"; -import { Node } from "../contributes/scriptTreeDataProvider"; +import { Node } from "../providers/scriptTreeDataProvider"; import { IHttpService } from "./httpService"; +import { IScmService } from "./scmService"; export interface IScriptService { viewScriptDetails(params: ArchiveListItem): Promise; @@ -19,6 +20,7 @@ export class ScriptService implements IScriptService { constructor( private readonly httpService: IHttpService, private readonly fileSystemService: IFileSystemService, + private readonly scriptSourceControlService: IScmService, ) {} /** @@ -59,6 +61,7 @@ export class ScriptService implements IScriptService { params.archiveListItem?.primaryKey ?? 0, ); const filePath = await this.fileSystemService.writeScriptToFile(scriptEntity, params); + await this.scriptSourceControlService.trackScript(filePath, scriptEntity.sourceCode ?? ""); const document = await workspace.openTextDocument(filePath); await window.showTextDocument(document); From b7c2d565935619f4a08232cbecbb1b0e316191b9 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:25:39 +0100 Subject: [PATCH 03/11] Update packages/vscode-extension/src/providers/authenticationProvider.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/providers/authenticationProvider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/providers/authenticationProvider.ts b/packages/vscode-extension/src/providers/authenticationProvider.ts index f795330..5015239 100644 --- a/packages/vscode-extension/src/providers/authenticationProvider.ts +++ b/packages/vscode-extension/src/providers/authenticationProvider.ts @@ -49,7 +49,12 @@ export class SuperOfficeAuthenticationProvider implements AuthenticationProvider * @returns true if the session is expired, false otherwise */ private isSessionExpired(session: SuperOfficeAuthenticationSession): boolean { - return session.expiresAt! < Date.now(); + const expiresAt = session.expiresAt; + if (typeof expiresAt !== "number") { + // Treat sessions without a valid expiresAt as expired/invalid + return true; + } + return expiresAt < Date.now(); } /** From 2c640e6698cbcea7bc16636d2acf535668cf9b6d Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:27:37 +0100 Subject: [PATCH 04/11] Removed leftovers from ShowGreeting command --- packages/vscode-extension/package.json | 4 ---- packages/vscode-extension/src/contributes/commands.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index cd60ec7..d3fea57 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -23,10 +23,6 @@ }, "contributes": { "commands": [ - { - "command": "ejfasting.showGreeting", - "title": "Hello World TS" - }, { "command": "ejfasting.authenticate", "title": "Authenticate with SuperOffice" diff --git a/packages/vscode-extension/src/contributes/commands.ts b/packages/vscode-extension/src/contributes/commands.ts index 971f481..014880f 100644 --- a/packages/vscode-extension/src/contributes/commands.ts +++ b/packages/vscode-extension/src/contributes/commands.ts @@ -8,7 +8,6 @@ import { ScriptService } from "../services/scriptService"; import { IScmService } from "../services/scmService"; export enum Commands { - ShowGreeting = "ejfasting.showGreeting", Authenticate = "ejfasting.authenticate", ViewScriptDetails = "ejfasting.script.viewDetails", DownloadScript = "ejfasting.script.download", From fde024eab6ab38e612ee29df165fe71a9d5cd123 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:30:52 +0100 Subject: [PATCH 05/11] Moved viewsWelcome to correct view, which is only visible when not authenticated. --- packages/vscode-extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index d3fea57..24bd3a9 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -83,7 +83,7 @@ }, "viewsWelcome": [ { - "view": "ejfasting.view.scripts", + "view": "ejfasting.view.empty", "when": "!superoffice.isAuthenticated", "contents": "You are not logged in to SuperOffice [learn more](https://docs.superoffice.com/).\n[Login](command:ejfasting.authenticate)" } From 78508bf48a8324ae05c7c118e03230c974262934 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:32:52 +0100 Subject: [PATCH 06/11] Update packages/vscode-extension/src/contributes/sourceControl.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/vscode-extension/src/contributes/sourceControl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode-extension/src/contributes/sourceControl.ts b/packages/vscode-extension/src/contributes/sourceControl.ts index acb28d2..59f3ca3 100644 --- a/packages/vscode-extension/src/contributes/sourceControl.ts +++ b/packages/vscode-extension/src/contributes/sourceControl.ts @@ -14,6 +14,7 @@ export async function registerSourceControl( context.subscriptions.push( workspace.registerTextDocumentContentProvider(ORIGINAL_SCHEME, scmTextDocumentContentProvider), ); + context.subscriptions.push(scmTextDocumentContentProvider); const scmService = new ScmService(fileSystemHandler, scmTextDocumentContentProvider); context.subscriptions.push(scmService); From 5e22969e410bb0f01dd62f10387e63a0c61899fa Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:33:18 +0100 Subject: [PATCH 07/11] Update packages/vscode-extension/src/services/scmService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/vscode-extension/src/services/scmService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/services/scmService.ts b/packages/vscode-extension/src/services/scmService.ts index 398b7f1..18f744b 100644 --- a/packages/vscode-extension/src/services/scmService.ts +++ b/packages/vscode-extension/src/services/scmService.ts @@ -167,7 +167,7 @@ export class ScmService implements IScmService { this._debounceTimer = setTimeout(() => void this._refresh(), 300); } - /** Computes the SHA1 hex digest of a string, matching Git's blob hashing. */ + /** Computes the SHA1 hex digest of the given string content (UTF-8). */ private _computeSha1(content: string): string { return createHash("sha1").update(content, "utf8").digest("hex"); } From 9d2f93fb799ff9c2f018b267cfdb82ddd91b0f40 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:34:38 +0100 Subject: [PATCH 08/11] Removed httpservice from registerCommands --- packages/vscode-extension/src/contributes/commands.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/vscode-extension/src/contributes/commands.ts b/packages/vscode-extension/src/contributes/commands.ts index 014880f..6b095c1 100644 --- a/packages/vscode-extension/src/contributes/commands.ts +++ b/packages/vscode-extension/src/contributes/commands.ts @@ -2,7 +2,6 @@ import { authentication, commands, window, SourceControlResourceState } from "vs import { packagePublisher } from "../extension"; import { SuperOfficeAuthenticationSession } from "../providers/authenticationProvider.types"; import { ArchiveListItem } from "@superoffice/webapi"; -import { HttpService } from "../services/httpService"; import { Node } from "../providers/scriptTreeDataProvider"; import { ScriptService } from "../services/scriptService"; import { IScmService } from "../services/scmService"; @@ -17,7 +16,6 @@ export enum Commands { } export async function registerCommands( - httpService: HttpService, scriptService: ScriptService, scriptSourceControlService: IScmService, ) { From 7c57b1bb4709c923b6f4a73546ec3681a3c4ae85 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:35:29 +0100 Subject: [PATCH 09/11] Update packages/vscode-extension/src/services/scmService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/vscode-extension/src/services/scmService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode-extension/src/services/scmService.ts b/packages/vscode-extension/src/services/scmService.ts index 18f744b..4b1f65d 100644 --- a/packages/vscode-extension/src/services/scmService.ts +++ b/packages/vscode-extension/src/services/scmService.ts @@ -158,6 +158,7 @@ export class ScmService implements IScmService { this._sourceControl.dispose(); this._changesGroup.dispose(); this._disposables.forEach((d) => d.dispose()); + this.scmTextDocumentContentProvider.dispose(); } private _scheduleRefresh(): void { From 92451041b1eab82bcbd8f79321bb3e3e9c17fb4e Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:38:03 +0100 Subject: [PATCH 10/11] fixed webapi setup only if currentSession is found --- packages/vscode-extension/src/extension.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index ad4cb42..743d16e 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -38,10 +38,15 @@ export async function activate(context: ExtensionContext) { * This is called whenever the authentication session changes. It clears the cached WebApi instance and sets up a new one with the current session. It also refreshes the script and extra tables tree data providers to reflect any changes. */ authProvider.onDidChangeSessions(() => { - webApi = null; // Clear cached WebApi instance on session change - webApi = setupWebApi(authProvider.getCurrentSession()!); scriptTreeDataProvider.refresh(); extraTablesTreeDataProvider.refresh(); + + webApi = null; // Clear cached WebApi instance on session change + const currentSession = authProvider.getCurrentSession(); + if (!currentSession) { + return; + } + webApi = setupWebApi(currentSession); }); } From 32876acb2cfc657fa2dabc7eda340978343e4569 Mon Sep 17 00:00:00 2001 From: Eivind Fasting Date: Sat, 7 Mar 2026 00:39:16 +0100 Subject: [PATCH 11/11] fixed bug --- packages/vscode-extension/src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 743d16e..ad7b72b 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -27,7 +27,7 @@ export async function activate(context: ExtensionContext) { authenticationService, ); - await registerCommands(httpService, scriptService, scmService); + await registerCommands(scriptService, scmService); const { scriptTreeDataProvider, extraTablesTreeDataProvider } = registerViews( context, authProvider,