diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 70353fee..9bf33fcf 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1192,6 +1192,8 @@ "MCP server": "MCP server", "expose the workspace": "expose the workspace", "Enable MCP server": "Enable MCP server", + "Enable Git sync": "Enable Git sync", + "Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.", "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.", "Resolves to {{url}}": "Resolves to {{url}}", "Model": "Model", diff --git a/apps/client/src/features/space/components/edit-space-form.tsx b/apps/client/src/features/space/components/edit-space-form.tsx index fae8de11..8bf176d9 100644 --- a/apps/client/src/features/space/components/edit-space-form.tsx +++ b/apps/client/src/features/space/components/edit-space-form.tsx @@ -1,5 +1,14 @@ -import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core"; -import React from "react"; +import { + Group, + Box, + Button, + TextInput, + Stack, + Textarea, + Divider, + Switch, +} from "@mantine/core"; +import React, { useState } from "react"; import { useForm } from "@mantine/form"; import { zod4Resolver } from "mantine-form-zod-resolver"; import { z } from "zod/v4"; @@ -29,6 +38,23 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { const { t } = useTranslation(); const updateSpaceMutation = useUpdateSpaceMutation(); + const [gitSyncEnabled, setGitSyncEnabled] = useState( + space?.settings?.gitSync?.enabled ?? false, + ); + + const handleGitSyncToggle = async (value: boolean) => { + const previous = gitSyncEnabled; + setGitSyncEnabled(value); // optimistic update + try { + await updateSpaceMutation.mutateAsync({ + spaceId: space.id, + gitSyncEnabled: value, + }); + } catch (err) { + setGitSyncEnabled(previous); // revert on failure + } + }; + const form = useForm({ validate: zod4Resolver(formSchema), initialValues: { @@ -104,6 +130,18 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { )} + + + + + handleGitSyncToggle(event.currentTarget.checked) + } + /> ); diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index c856d88a..a45b5185 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -13,9 +13,14 @@ export interface ISpaceCommentsSettings { allowViewerComments?: boolean; } +export interface ISpaceGitSyncSettings { + enabled?: boolean; +} + export interface ISpaceSettings { sharing?: ISpaceSharingSettings; comments?: ISpaceCommentsSettings; + gitSync?: ISpaceGitSyncSettings; } export interface ISpace { @@ -35,6 +40,7 @@ export interface ISpace { // for updates disablePublicSharing?: boolean; allowViewerComments?: boolean; + gitSyncEnabled?: boolean; } interface IMembership { diff --git a/apps/server/src/core/space/dto/update-space.dto.ts b/apps/server/src/core/space/dto/update-space.dto.ts index 8b40e894..07196d01 100644 --- a/apps/server/src/core/space/dto/update-space.dto.ts +++ b/apps/server/src/core/space/dto/update-space.dto.ts @@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { @IsOptional() @IsBoolean() allowViewerComments: boolean; + + @IsOptional() + @IsBoolean() + gitSyncEnabled?: boolean; } diff --git a/apps/server/src/core/space/services/space.service.spec.ts b/apps/server/src/core/space/services/space.service.spec.ts index befdf06c..50f47ea7 100644 --- a/apps/server/src/core/space/services/space.service.spec.ts +++ b/apps/server/src/core/space/services/space.service.spec.ts @@ -22,4 +22,75 @@ describe('SpaceService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('updateSpace gitSyncEnabled', () => { + const workspaceId = 'ws-1'; + const spaceId = 'space-1'; + + // executeTx runs the callback immediately with a passthrough trx so the + // repo calls happen inline; mirrors how the sibling sharing/comments flags + // are persisted. + const buildService = (settingsBefore: Record) => { + const spaceRepo = { + findById: jest.fn().mockResolvedValue({ + id: spaceId, + name: 'Space', + slug: 'space', + description: '', + settings: settingsBefore, + }), + updateGitSyncSettings: jest.fn().mockResolvedValue({}), + updateSharingSettings: jest.fn().mockResolvedValue({}), + updateCommentSettings: jest.fn().mockResolvedValue({}), + updateSpace: jest + .fn() + .mockResolvedValue({ id: spaceId, name: 'Space', slug: 'space' }), + slugExists: jest.fn().mockResolvedValue(false), + }; + const auditService = { log: jest.fn() }; + + const svc = new SpaceService( + spaceRepo as any, + {} as any, // spaceMemberService + {} as any, // shareRepo + {} as any, // workspaceRepo + {} as any, // licenseCheckService + {} as any, // db + {} as any, // attachmentQueue + auditService as any, + ); + + // executeTx is invoked via the imported helper; patch it on the module. + jest + .spyOn(require('@docmost/db/utils'), 'executeTx') + .mockImplementation(async (_db: any, cb: any) => cb({} as any)); + + return { svc, spaceRepo, auditService }; + }; + + it('persists gitSyncEnabled via updateGitSyncSettings(enabled)', async () => { + const { svc, spaceRepo } = buildService({}); + + await svc.updateSpace( + { spaceId, gitSyncEnabled: true } as any, + workspaceId, + ); + + expect(spaceRepo.updateGitSyncSettings).toHaveBeenCalledWith( + spaceId, + workspaceId, + 'enabled', + true, + expect.anything(), + ); + }); + + it('does not call updateGitSyncSettings when flag is undefined', async () => { + const { svc, spaceRepo } = buildService({}); + + await svc.updateSpace({ spaceId } as any, workspaceId); + + expect(spaceRepo.updateGitSyncSettings).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 2675a9e6..78d3c359 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -213,6 +213,22 @@ export class SpaceService { ); } + if (typeof updateSpaceDto.gitSyncEnabled !== 'undefined') { + const prev = settingsBefore?.gitSync?.enabled ?? false; + if (prev !== updateSpaceDto.gitSyncEnabled) { + before.gitSyncEnabled = prev; + after.gitSyncEnabled = updateSpaceDto.gitSyncEnabled; + } + + await this.spaceRepo.updateGitSyncSettings( + updateSpaceDto.spaceId, + workspaceId, + 'enabled', + updateSpaceDto.gitSyncEnabled, + trx, + ); + } + updatedSpace = await this.spaceRepo.updateSpace( { name: updateSpaceDto.name, diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 0b389665..28e75dc7 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -111,6 +111,28 @@ export class SpaceRepo { .executeTakeFirst(); } + async updateGitSyncSettings( + spaceId: string, + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('spaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('gitSync', COALESCE(settings->'gitSync', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .returningAll() + .executeTakeFirst(); + } + async updateCommentSettings( spaceId: string, workspaceId: string,