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 a4c2828..24bd3a9 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" @@ -42,6 +38,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": { @@ -58,18 +64,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", + "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)" } ], @@ -86,6 +101,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..6b095c1 100644 --- a/packages/vscode-extension/src/contributes/commands.ts +++ b/packages/vscode-extension/src/contributes/commands.ts @@ -1,27 +1,24 @@ -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", Authenticate = "ejfasting.authenticate", 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( + scriptService: ScriptService, + scriptSourceControlService: IScmService, +) { commands.registerCommand(Commands.Authenticate, async () => { const session = (await authentication.getSession(`${packagePublisher.toLowerCase()}`, [], { createIfNone: true, @@ -43,4 +40,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/extraTablesTreeDataProvider.ts b/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts deleted file mode 100644 index 634fbec..0000000 --- a/packages/vscode-extension/src/contributes/extraTablesTreeDataProvider.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - TreeItem, - TreeItemCollapsibleState, - ThemeIcon, - TreeDataProvider, - EventEmitter, - Event, - ExtensionContext, -} from "vscode"; -import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; -import { IHttpService } from "../services/httpService"; -import { ArchiveListItem } from "@superoffice/webapi"; - -export class ExtraTablesNode implements TreeItem { - contextValue: string; - collapsibleState: TreeItemCollapsibleState; - - constructor( - public readonly label: string, - public readonly children?: ExtraTablesNode[], - public readonly iconPath?: ThemeIcon, - ) { - this.contextValue = children && children.length > 0 ? "extraTablesParent" : "extraTablesChild"; - this.collapsibleState = - children && children.length > 0 - ? TreeItemCollapsibleState.Collapsed - : TreeItemCollapsibleState.None; - } -} - -export class ExtraTablesTreeDataProvider implements TreeDataProvider { - private _onDidChangeTreeData: EventEmitter = new EventEmitter< - ExtraTablesNode | undefined - >(); - readonly onDidChangeTreeData: Event = - this._onDidChangeTreeData.event; - - constructor( - _context: ExtensionContext, - private authProvider: SuperOfficeAuthenticationProvider, - private httpService: IHttpService, - ) {} - - // Call this method to trigger a refresh of the tree view - public refresh(): void { - this._onDidChangeTreeData.fire(undefined); - } - - getTreeItem(element: ExtraTablesNode): TreeItem { - return element; - } - - async getChildren(element?: ExtraTablesNode): Promise { - if (element) { - return element.children || []; - } - - const currentSession = this.authProvider.getCurrentSession(); - if (currentSession) { - try { - 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); - } - }); - - 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 []; - } -} diff --git a/packages/vscode-extension/src/contributes/sourceControl.ts b/packages/vscode-extension/src/contributes/sourceControl.ts new file mode 100644 index 0000000..59f3ca3 --- /dev/null +++ b/packages/vscode-extension/src/contributes/sourceControl.ts @@ -0,0 +1,24 @@ +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), + ); + context.subscriptions.push(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 76042f3..ad7b72b 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,19 +27,26 @@ export async function activate(context: ExtensionContext) { authenticationService, ); - await registerCommands(httpService, scriptService); + await registerCommands(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 - window.showInformationMessage("Authentication session changed. Refreshing views..."); - 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); }); } @@ -50,11 +59,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..5015239 --- /dev/null +++ b/packages/vscode-extension/src/providers/authenticationProvider.ts @@ -0,0 +1,262 @@ +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 { + const expiresAt = session.expiresAt; + if (typeof expiresAt !== "number") { + // Treat sessions without a valid expiresAt as expired/invalid + return true; + } + return 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/providers/extraTablesTreeDataProvider.ts b/packages/vscode-extension/src/providers/extraTablesTreeDataProvider.ts new file mode 100644 index 0000000..e891a70 --- /dev/null +++ b/packages/vscode-extension/src/providers/extraTablesTreeDataProvider.ts @@ -0,0 +1,126 @@ +import { + TreeItem, + TreeItemCollapsibleState, + ThemeIcon, + TreeDataProvider, + EventEmitter, + Event, + ExtensionContext, + window, + ProgressLocation, +} from "vscode"; +import { SuperOfficeAuthenticationProvider } from "./authenticationProvider"; +import { IHttpService } from "../services/httpService"; +import { ArchiveListItem } from "@superoffice/webapi"; + +export class ExtraTablesNode implements TreeItem { + contextValue: string; + collapsibleState: TreeItemCollapsibleState; + + constructor( + public readonly label: string, + public readonly children?: ExtraTablesNode[], + public readonly iconPath?: ThemeIcon, + ) { + this.contextValue = children && children.length > 0 ? "extraTablesParent" : "extraTablesChild"; + this.collapsibleState = + children && children.length > 0 + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None; + } +} + +export class ExtraTablesTreeDataProvider implements TreeDataProvider { + private _onDidChangeTreeData: EventEmitter = new EventEmitter< + ExtraTablesNode | undefined + >(); + readonly onDidChangeTreeData: Event = + this._onDidChangeTreeData.event; + + constructor( + _context: ExtensionContext, + private authProvider: SuperOfficeAuthenticationProvider, + private httpService: IHttpService, + ) {} + + // Call this method to trigger a refresh of the tree view + public refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: ExtraTablesNode): TreeItem { + return element; + } + + async getChildren(element?: ExtraTablesNode): Promise { + if (element) { + return element.children || []; + } + + const currentSession = this.authProvider.getCurrentSession(); + if (currentSession) { + 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); + } + }); + + 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; + }, + ); + } + return []; + } +} 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 84% rename from packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts rename to packages/vscode-extension/src/providers/scriptTreeDataProvider.ts index 047c4d8..d0c2b94 100644 --- a/packages/vscode-extension/src/contributes/scriptTreeDataProvider.ts +++ b/packages/vscode-extension/src/providers/scriptTreeDataProvider.ts @@ -8,11 +8,13 @@ import { EventEmitter, Event, ExtensionContext, + window, + ProgressLocation, } from "vscode"; 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; @@ -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/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..4b1f65d --- /dev/null +++ b/packages/vscode-extension/src/services/scmService.ts @@ -0,0 +1,254 @@ +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()); + this.scmTextDocumentContentProvider.dispose(); + } + + private _scheduleRefresh(): void { + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + this._debounceTimer = setTimeout(() => void this._refresh(), 300); + } + + /** 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"); + } + + /** 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);