diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2b2afec71d..8e5a711afc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1241,6 +1241,38 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ``` +## GitHub Octicons + +- Component: `gitBranch` (upstream `git-branch-16`) icon path data in + `opensquilla-webui/src/utils/icons.ts` and the generated Web UI assets. +- Upstream project: https://github.com/primer/octicons +- License: MIT +- Copyright notice: Copyright (c) 2026 GitHub Inc. + +``` +MIT License + +Copyright (c) 2026 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## Feather Icons - Component: `music`, `pause`, and `volume` icon path data in diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index 72069cf82c..0be8721874 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -473,6 +473,18 @@ "schema": "approvals/approval-status.schema.json", "schemaSha256": "951d02cc9cb10329793513cdb100163dcd33b75e2c9053be2042899aff4fba8f" }, + { + "lifecycle": "stable", + "name": "git.diff", + "schema": "git/git-diff.schema.json", + "schemaSha256": "22b89de2e1c98887b2995f7627645448a700d1e0ceb2545a322a3b8368660898" + }, + { + "lifecycle": "stable", + "name": "git.status", + "schema": "git/git-status.schema.json", + "schemaSha256": "f60ac943857bde95a57764346a1d926045fa8116aa651acee2e6d5ea8461b2a8" + }, { "lifecycle": "stable", "name": "goals.capabilities", @@ -1452,10 +1464,10 @@ "protocol": "opensquilla-websocket-json", "source": { "eventFamilyCount": 10, - "generatorSha256": "829c5afc1ad4481e06e232cd30f7746db621efafcc9c29f6dcb01e7eb3b79cad", - "methodCount": 213, - "schemaCount": 223, - "schemaTreeSha256": "80c689d229c795f27e43c797468cd96a83eba981383b7e5e4bf4d7a4c9d0689c", + "generatorSha256": "39ff9a0e523eba24c4e87b457ff16caf3832e21db8bfb37b808d805bb8e6d25b", + "methodCount": 215, + "schemaCount": 225, + "schemaTreeSha256": "171a781c2707c1e50b974bcffcd826fadf9f5bf6705619ffe80f2f52082fda25", "toolchains": { "legacyTypes": { "python": { diff --git a/contracts/gateway/v4/git/git-diff.schema.json b/contracts/gateway/v4/git/git-diff.schema.json new file mode 100644 index 0000000000..8f24ec4782 --- /dev/null +++ b/contracts/gateway/v4/git/git-diff.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json", + "title": "OpenSquilla git.diff Contract", + "type": "object", + "additionalProperties": false, + "properties": {"request": {"$ref": "#/$defs/RequestFrame"}, "response": {"$ref": "#/$defs/ResponseFrame"}}, + "x-opensquilla-wire": {"protocol": "opensquilla-websocket-json", "version": 4, "compatibility": "exact-json-tree"}, + "x-opensquilla-codegen": {"python": {"tool": "datamodel-code-generator", "version": "0.81.0", "target": "pydantic_v2.BaseModel"}, "typescript": {"tool": "json-schema-to-typescript", "version": "16.0.0"}, "runtimeValidation": {"tool": "ajv", "version": "8.20.0", "mode": "standalone-adapter-only"}}, + "x-opensquilla-method": {"name": "git.diff", "kind": "query", "scope": "operator.read", "guestAllowed": false, "idempotency": "read-only", "timeout": {"policy": "transport"}, "capability": {"kind": "method-availability", "name": "git.diff"}, "request": "#/$defs/RequestFrame", "params": "#/$defs/Params", "response": "#/$defs/ResponseFrame", "result": "#/$defs/Result", "errors": [{"code": "OWNER_REQUIRED"}, {"code": "INVALID_PARAMS"}, {"code": "GIT_UNAVAILABLE"}, {"code": "INTERNAL_ERROR"}]}, + "$defs": { + "Params": {"type": "object", "additionalProperties": false, "required": ["path"], "properties": {"path": {"description": "Path relative to the repository root.", "type": "string"}, "workspaceId": {"description": "Project workspace id; null or omitted means the current session default working directory.", "type": ["string", "null"]}, "staged": {"type": "boolean"}}}, + "Diff": {"type": "object", "additionalProperties": false, "required": ["path", "diff", "binary", "truncated", "additions", "deletions"], "properties": {"path": {"type": "string"}, "diff": {"description": "Unified diff text.", "type": "string"}, "binary": {"type": "boolean"}, "truncated": {"description": "Whether an over-long diff was truncated.", "type": "boolean"}, "additions": {"type": "integer", "minimum": 0}, "deletions": {"type": "integer", "minimum": 0}}}, + "Result": {"type": "object", "additionalProperties": false, "required": ["diff"], "properties": {"diff": {"$ref": "#/$defs/Diff"}}}, + "RpcError": {"type": "object", "additionalProperties": true}, + "RequestFrame": {"type": "object", "additionalProperties": true, "required": ["type", "id", "method"], "properties": {"type": {"const": "req"}, "id": {"type": "string"}, "method": {"const": "git.diff"}, "params": {"$ref": "#/$defs/Params"}}}, + "ResponseFrame": {"type": "object", "additionalProperties": true, "required": ["type", "id", "ok"], "properties": {"type": {"const": "res"}, "id": {"type": "string"}, "ok": {"type": "boolean"}, "payload": {"$ref": "#/$defs/Result"}, "error": {"$ref": "#/$defs/RpcError"}}} + } +} diff --git a/contracts/gateway/v4/git/git-status.schema.json b/contracts/gateway/v4/git/git-status.schema.json new file mode 100644 index 0000000000..ef4dbe6cc3 --- /dev/null +++ b/contracts/gateway/v4/git/git-status.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json", + "title": "OpenSquilla git.status Contract", + "type": "object", + "additionalProperties": false, + "properties": {"request": {"$ref": "#/$defs/RequestFrame"}, "response": {"$ref": "#/$defs/ResponseFrame"}}, + "x-opensquilla-wire": {"protocol": "opensquilla-websocket-json", "version": 4, "compatibility": "exact-json-tree"}, + "x-opensquilla-codegen": {"python": {"tool": "datamodel-code-generator", "version": "0.81.0", "target": "pydantic_v2.BaseModel"}, "typescript": {"tool": "json-schema-to-typescript", "version": "16.0.0"}, "runtimeValidation": {"tool": "ajv", "version": "8.20.0", "mode": "standalone-adapter-only"}}, + "x-opensquilla-method": {"name": "git.status", "kind": "query", "scope": "operator.read", "guestAllowed": false, "idempotency": "read-only", "timeout": {"policy": "transport"}, "capability": {"kind": "method-availability", "name": "git.status"}, "request": "#/$defs/RequestFrame", "params": "#/$defs/Params", "response": "#/$defs/ResponseFrame", "result": "#/$defs/Result", "errors": [{"code": "OWNER_REQUIRED"}, {"code": "INVALID_PARAMS"}, {"code": "GIT_UNAVAILABLE"}, {"code": "INTERNAL_ERROR"}]}, + "$defs": { + "Params": {"type": "object", "additionalProperties": false, "properties": {"workspaceId": {"description": "Project workspace id; null or omitted means the current session default working directory.", "type": ["string", "null"]}}}, + "ChangedFile": {"type": "object", "additionalProperties": false, "required": ["path", "status", "staged"], "properties": {"path": {"type": "string"}, "status": {"enum": ["modified", "added", "deleted", "renamed", "untracked", "conflicted"]}, "staged": {"type": "boolean"}, "previousPath": {"type": ["string", "null"]}}}, + "Status": {"type": "object", "additionalProperties": false, "required": ["available", "workspacePath", "branch", "detached", "unborn", "headOid", "ahead", "behind", "clean", "files", "reason"], "properties": {"available": {"description": "Whether the directory is a usable git repository.", "type": "boolean"}, "workspacePath": {"description": "Resolved absolute path of the repository.", "type": ["string", "null"]}, "branch": {"description": "Branch name; null while detached.", "type": ["string", "null"]}, "detached": {"type": "boolean"}, "unborn": {"description": "Branch without any commit yet.", "type": "boolean"}, "headOid": {"description": "Full HEAD commit id.", "type": ["string", "null"]}, "ahead": {"type": "integer", "minimum": 0}, "behind": {"type": "integer", "minimum": 0}, "clean": {"type": "boolean"}, "files": {"type": "array", "items": {"$ref": "#/$defs/ChangedFile"}}, "reason": {"description": "Why the repository is unavailable, such as not_repository or git_unavailable.", "type": ["string", "null"]}}}, + "Result": {"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"$ref": "#/$defs/Status"}}}, + "RpcError": {"type": "object", "additionalProperties": true}, + "RequestFrame": {"type": "object", "additionalProperties": true, "required": ["type", "id", "method"], "properties": {"type": {"const": "req"}, "id": {"type": "string"}, "method": {"const": "git.status"}, "params": {"$ref": "#/$defs/Params"}}}, + "ResponseFrame": {"type": "object", "additionalProperties": true, "required": ["type", "id", "ok"], "properties": {"type": {"const": "res"}, "id": {"type": "string"}, "ok": {"type": "boolean"}, "payload": {"$ref": "#/$defs/Result"}, "error": {"$ref": "#/$defs/RpcError"}}} + } +} diff --git a/contracts/gateway/v4/production-targets.json b/contracts/gateway/v4/production-targets.json index bc84cd3f70..c66d61ce04 100644 --- a/contracts/gateway/v4/production-targets.json +++ b/contracts/gateway/v4/production-targets.json @@ -1,6 +1,22 @@ { "format": 1, "targets": [ + { + "kind": "method", + "wireName": "git.status", + "roles": [ + "params", + "result" + ] + }, + { + "kind": "method", + "wireName": "git.diff", + "roles": [ + "params", + "result" + ] + }, { "kind": "method", "wireName": "sessions.messages.search", diff --git a/opensquilla-webui/src/App.git-branch.test.ts b/opensquilla-webui/src/App.git-branch.test.ts new file mode 100644 index 0000000000..5791f06012 --- /dev/null +++ b/opensquilla-webui/src/App.git-branch.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue' + +import appSource from './App.vue?raw' +import GitBranchChip from './components/chat/GitBranchChip.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => ( + params?.branch ? `${key}|${String(params.branch)}` : key + ), + }), +})) + +const apps: App[] = [] + +afterEach(() => { + apps.splice(0).forEach(app => app.unmount()) + document.body.innerHTML = '' +}) + +async function mountChip(initial: { label: string; dirtyCount: number; loading?: boolean }) { + const props = reactive({ ...initial }) + let opened = 0 + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(defineComponent(() => () => h(GitBranchChip, { + ...props, + onOpen: () => { opened += 1 }, + }))) + apps.push(app) + app.mount(root) + await nextTick() + return { root, props, opened: () => opened, button: () => root.querySelector('button')! } +} + +describe('App git branch chip contract', () => { + it('mounts the branch chip as the first topbar-right control on chat routes', () => { + const rightStart = appSource.indexOf('
') + const rightEnd = appSource.indexOf('\n { + expect(appSource).toContain("import GitBranchChip from './components/chat/GitBranchChip.vue'") + expect(appSource).toContain('const gitAccess = useGitAccess()') + expect(appSource).toContain('chatRouteHeader.model.workspaceId.value ?? null') + expect(appSource).toContain('active: computed(() => isChatRoute.value)') + + const handlerStart = appSource.indexOf('function openGitChanges()') + const handlerEnd = appSource.indexOf('\n}', handlerStart) + const handler = appSource.slice(handlerStart, handlerEnd) + expect(handler).toContain("id: 'git-changes'") + expect(handler).toContain("kind: 'git-changes'") + expect(handler).toContain("title: t('workbench.gitChanges.title')") + expect(handler).toContain('workbenchStore.openItem(item, { activate: true })') + expect(handler).toContain('workbenchStore.setExpanded(true)') + }) +}) + +describe('GitBranchChip', () => { + it('renders the branch name and opens the workbench on click', async () => { + const { button, opened } = await mountChip({ label: 'main', dirtyCount: 0 }) + + expect(button().getAttribute('type')).toBe('button') + expect(button().textContent).toContain('main') + expect(button().getAttribute('title')).toBe('workbench.gitChanges.openHint|main') + expect(button().getAttribute('aria-label')).toBe('workbench.gitChanges.openHint|main') + expect(button().querySelector('.git-branch-chip__dirty')).toBeNull() + + button().click() + await nextTick() + expect(opened()).toBe(1) + }) + + it('marks a dirty working tree with a star after the branch name', async () => { + const { button } = await mountChip({ label: 'main', dirtyCount: 3 }) + + expect(button().querySelector('.git-branch-chip__dirty')?.textContent).toBe('*') + expect(button().textContent?.trim()).toBe('main*') + }) + + it('stays quiet while a read is in flight instead of flashing', async () => { + const { button, props, root } = await mountChip({ label: 'main', dirtyCount: 0, loading: true }) + + expect(button().getAttribute('aria-busy')).toBe('true') + expect(button().classList.contains('git-branch-chip--loading')).toBe(true) + expect(root.querySelector('.git-branch-chip__label')?.textContent).toBe('main') + + props.loading = false + await nextTick() + expect(button().getAttribute('aria-busy')).toBeNull() + expect(button().classList.contains('git-branch-chip--loading')).toBe(false) + }) +}) diff --git a/opensquilla-webui/src/App.vue b/opensquilla-webui/src/App.vue index 8db460ce71..e30cb2ffd1 100644 --- a/opensquilla-webui/src/App.vue +++ b/opensquilla-webui/src/App.vue @@ -254,6 +254,13 @@ />
+ { const workbenchSessionKey = computed(() => currentSessionKey.value || chatRouteHeader.model.sessionKey.value, ) +// The branch chip reads the current session's working directory. A null +// workspaceId means "session default", so nothing has to be resolved here. +const gitAccess = useGitAccess() +const gitWorkspaceId = computed(() => chatRouteHeader.model.workspaceId.value ?? null) +const gitStatus = useGitStatus({ + access: computed(() => gitAccess), + workspaceId: gitWorkspaceId, + active: computed(() => isChatRoute.value), +}) + +// One stable id per session scope: clicking the chip again re-activates the +// same tab instead of stacking duplicates. +function openGitChanges() { + const sessionKey = workbenchSessionKey.value + const scope: WorkbenchScope = sessionKey + ? { type: 'session', id: sessionKey } + : { type: 'app' } + const item: WorkbenchItem = { + id: 'git-changes', + kind: 'git-changes', + title: t('workbench.gitChanges.title'), + scope, + hostKind: 'dom', + retention: 'keep-alive', + // The panel renders outside this route, so the repository it reads travels + // with the item; null keeps the session-default working directory. + payload: { workspaceId: gitWorkspaceId.value }, + } + workbenchStore.openItem(item, { activate: true }) + workbenchStore.setExpanded(true) +} const sessionTaskAttention = useSessionTaskAttention() function currentSessionIsVisible(): boolean { diff --git a/opensquilla-webui/src/adapters/gateway/gatewayAdapters.test.ts b/opensquilla-webui/src/adapters/gateway/gatewayAdapters.test.ts index 6d614632d6..1e364b731b 100644 --- a/opensquilla-webui/src/adapters/gateway/gatewayAdapters.test.ts +++ b/opensquilla-webui/src/adapters/gateway/gatewayAdapters.test.ts @@ -65,6 +65,7 @@ describe('Gateway Adapter composition', () => { 'workspaceReferences', 'workspaceFiles', 'terminal', + 'git', 'sandboxRuntime', 'usageReporting', 'commandCatalog', diff --git a/opensquilla-webui/src/adapters/gateway/gatewayAdapters.ts b/opensquilla-webui/src/adapters/gateway/gatewayAdapters.ts index 3aba1984b8..b415d82cb2 100644 --- a/opensquilla-webui/src/adapters/gateway/gatewayAdapters.ts +++ b/opensquilla-webui/src/adapters/gateway/gatewayAdapters.ts @@ -40,6 +40,8 @@ import type { WorkspaceFiles } from '@/modules/workspaceFiles' import { createV4WorkspaceFiles } from './workspaceFilesV4' import type { TerminalAccess } from '@/modules/terminal' import { createV4TerminalAccess } from './terminalV4' +import type { GitAccess } from '@/modules/git' +import { createGitAccess } from './gitV4' import type { SandboxRuntime } from '@/modules/sandboxRuntime' import { createV4SandboxRuntime } from './sandboxRuntimeV4' import type { UsageReporting } from '@/modules/usageReporting' @@ -111,6 +113,7 @@ export interface GatewayAdapters { readonly workspaceReferences: WorkspaceReferences readonly workspaceFiles: WorkspaceFiles readonly terminal: TerminalAccess + readonly git: GitAccess readonly sandboxRuntime: SandboxRuntime readonly usageReporting: UsageReporting readonly commandCatalog: CommandCatalog @@ -194,6 +197,7 @@ export function createGatewayAdapters( workspaceReferences: createV4WorkspaceReferences(transports.rpc), workspaceFiles: createV4WorkspaceFiles(http), terminal: createV4TerminalAccess(transports.rpc, transports.events), + git: createGitAccess(transports.rpc), sandboxRuntime: createV4SandboxRuntime(transports.rpc, transports.events), usageReporting: createV4UsageReporting(transports.rpc), commandCatalog: createV4CommandCatalog(transports.rpc), diff --git a/opensquilla-webui/src/adapters/gateway/gitV4.ts b/opensquilla-webui/src/adapters/gateway/gitV4.ts new file mode 100644 index 0000000000..e4e6491daf --- /dev/null +++ b/opensquilla-webui/src/adapters/gateway/gitV4.ts @@ -0,0 +1,64 @@ +import type { Params as GitDiffContractParams, Result as GitDiffContractResult } from '@/contracts/generated/v4/gitDiff' +import type { Params as GitStatusContractParams, Result as GitStatusContractResult } from '@/contracts/generated/v4/gitStatus' +import { validateParams as validateGitDiffParams, validateResult as validateGitDiffResult } from '@/contracts/generated/v4/gitDiffValidators.mjs' +import { validateParams as validateGitStatusParams, validateResult as validateGitStatusResult } from '@/contracts/generated/v4/gitStatusValidators.mjs' +import type { GitAccess, GitDiffPayload, GitStatusPayload } from '@/modules/git' +import type { TransportCallOptions } from './transportTypes' + +interface RpcTransport { + request(method: string, params?: object, options?: TransportCallOptions): Promise +} + +const METHODS = { + status: 'git.status', + diff: 'git.diff', +} as const + +const requestOptions = (signal?: AbortSignal): TransportCallOptions => ({ + timeoutMs: 15_000, + timeoutAction: 'reject', + abortAction: 'reject', + recoveryClass: 'ephemeral', + ...(signal ? { signal } : {}), +}) + +function invalid(method: string): Error { + return new Error(`${method} returned an invalid response`) +} + +/** + * The generated Contract validator owns field presence, nullability, enums and + * integer bounds, so a malformed payload never reaches the panel. Returning the + * Contract projection under the module type keeps both shapes in lockstep. + */ +function parseStatus(payload: unknown): GitStatusPayload { + if (!validateGitStatusResult(payload)) throw invalid(METHODS.status) + return (payload as GitStatusContractResult).status +} + +function parseDiff(payload: unknown): GitDiffPayload { + if (!validateGitDiffResult(payload)) throw invalid(METHODS.diff) + return (payload as GitDiffContractResult).diff +} + +export function createGitAccess(transport: RpcTransport): GitAccess { + return { + async status(options = {}) { + const params: GitStatusContractParams = { workspaceId: options.workspaceId ?? null } + if (!validateGitStatusParams(params)) throw new Error('git.status params violated Contract') + return parseStatus(await transport.request(METHODS.status, params, requestOptions(options.signal))) + }, + async diff(input) { + if (!input || typeof input.path !== 'string' || !input.path) { + throw new Error('invalid git.diff input') + } + const params: GitDiffContractParams = { + path: input.path, + workspaceId: input.workspaceId ?? null, + } + if (input.staged !== undefined) params.staged = input.staged + if (!validateGitDiffParams(params)) throw new Error('git.diff params violated Contract') + return parseDiff(await transport.request(METHODS.diff, params, requestOptions(input.signal))) + }, + } +} diff --git a/opensquilla-webui/src/components/chat/GitBranchChip.vue b/opensquilla-webui/src/components/chat/GitBranchChip.vue new file mode 100644 index 0000000000..c3eb364e9d --- /dev/null +++ b/opensquilla-webui/src/components/chat/GitBranchChip.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/opensquilla-webui/src/components/workbench/AppWorkbench.vue b/opensquilla-webui/src/components/workbench/AppWorkbench.vue index 08d073fccb..24d576d1ab 100644 --- a/opensquilla-webui/src/components/workbench/AppWorkbench.vue +++ b/opensquilla-webui/src/components/workbench/AppWorkbench.vue @@ -411,6 +411,7 @@ import { createWorkbenchResourceCollectionDefinition } from './workbenchResource import { WORKSPACE_REFERENCES_KEY } from '@/modules/workspaceReferences' import { WORKSPACE_FILES_KEY } from '@/modules/workspaceFiles' import { TERMINAL_ACCESS_KEY } from '@/modules/terminal' +import { useGitAccess } from '@/modules/git' import { GATEWAY_ACCESS_KEY } from '@/modules/gatewayAccess' import { SESSION_LIFECYCLE_KEY } from '@/modules/sessionLifecycle' import { SESSION_READ_LIFECYCLE_FACTORY_KEY } from '@/modules/sessionReadLifecycle' @@ -419,6 +420,7 @@ import { CONVERSATION_EVENTS_KEY } from '@/modules/conversationEvents' import { createWorkspaceFileDefinition } from './workspaceFileProvider' import { createWorkspaceFilesWorkbenchDefinition } from './workspaceFilesWorkbenchProvider' import { createTerminalWorkbenchDefinition } from './terminalWorkbenchProvider' +import { createGitChangesWorkbenchDefinition } from './gitChangesWorkbenchProvider' import { createAssistantChatWorkbenchDefinition } from './assistantChatWorkbenchProvider' import { createToolHistoryFileDefinition } from './toolHistoryFileProvider' import WorkbenchHost from './WorkbenchHost.vue' @@ -488,6 +490,9 @@ const releaseSideChatLauncher = sideChatLauncher?.register(launchSideChatCommand const workspaceReferences = inject(WORKSPACE_REFERENCES_KEY, null) const workspaceFiles = inject(WORKSPACE_FILES_KEY, null) const terminal = inject(TERMINAL_ACCESS_KEY, null) +// Git is a read-only owner published by main.ts; the module helper returns null +// outside the app shell (tests, isolated stories) instead of throwing. +const gitAccess = useGitAccess() const referenceGateway = inject(GATEWAY_ACCESS_KEY, null) const gateway = referenceGateway const assistantSessionLifecycle = inject(SESSION_LIFECYCLE_KEY, null) @@ -1317,6 +1322,10 @@ if (terminal) { key => String(t(key)), ), { replace: true }) } +workbenchPanelRegistry.register(createGitChangesWorkbenchDefinition( + gitAccess, + key => String(t(key)), +), { replace: true }) if (assistantPortsAvailable) { workbenchPanelRegistry.register(createAssistantChatWorkbenchDefinition( key => String(t(key)), diff --git a/opensquilla-webui/src/components/workbench/GitChangesPanel.test.ts b/opensquilla-webui/src/components/workbench/GitChangesPanel.test.ts new file mode 100644 index 0000000000..d62eb31966 --- /dev/null +++ b/opensquilla-webui/src/components/workbench/GitChangesPanel.test.ts @@ -0,0 +1,281 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick } from 'vue' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import { + GIT_ACCESS_KEY, + type GitAccess, + type GitDiffPayload, + type GitStatusPayload, +} from '@/modules/git' +import type { WorkbenchItem, WorkbenchPanelRenderState } from '@/workbench/types' +import GitChangesPanel from './GitChangesPanel.vue' +import { createGitChangesWorkbenchDefinition, gitChangesWorkspaceId } from './gitChangesWorkbenchProvider' + +const cleanups: Array<() => void> = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() + vi.restoreAllMocks() +}) + +function statusPayload(patch: Partial = {}): GitStatusPayload { + return { + available: true, + workspacePath: '/repo', + branch: 'main', + detached: false, + unborn: false, + headOid: 'abcdef1234567890', + ahead: 0, + behind: 0, + clean: false, + files: [], + reason: null, + ...patch, + } +} + +function diffPayload(patch: Partial = {}): GitDiffPayload { + return { + path: 'src/App.vue', + diff: '', + binary: false, + truncated: false, + additions: 0, + deletions: 0, + ...patch, + } +} + +function mountPanel( + access: GitAccess, + props: { workspaceId?: string | null; visible?: boolean } = { workspaceId: 'ws-1' }, +): HTMLElement { + const element = document.createElement('div') + document.body.append(element) + const app = createApp({ render: () => h(GitChangesPanel, props) }) + app.provide(GIT_ACCESS_KEY, access) + app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })).mount(element) + cleanups.push(() => { app.unmount(); element.remove() }) + return element +} + +function rows(element: HTMLElement): HTMLElement[] { + return [...element.querySelectorAll('[data-testid="git-changes-file"]')] +} + +function text(element: Element | null): string { + return element?.textContent?.replace(/\s+/g, ' ').trim() ?? '' +} + +describe('GitChangesPanel', () => { + it('renders staged, unstaged and untracked groups from one status read', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ + ahead: 2, + behind: 1, + files: [ + { path: 'src/App.vue', status: 'modified', staged: true }, + { path: 'src/modules/git.ts', status: 'modified', staged: false }, + { path: 'docs/notes.md', status: 'untracked', staged: false }, + ], + })), + diff: vi.fn(async () => diffPayload()), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(rows(element)).toHaveLength(3)) + + expect(access.status).toHaveBeenCalledTimes(1) + expect(text(element.querySelector('[data-testid="git-changes-panel"] .git-changes__branch'))).toContain('main') + expect(element.querySelectorAll('.git-changes__tracking')).toHaveLength(2) + expect(text(element.querySelector('.git-changes__count'))).toContain('3 changed') + + const staged = element.querySelector('[data-testid="git-changes-section-staged"]') + const unstaged = element.querySelector('[data-testid="git-changes-section-unstaged"]') + const untracked = element.querySelector('[data-testid="git-changes-section-untracked"]') + expect(text(staged)).toContain('Staged') + expect(text(staged)).toContain('App.vue') + expect(text(staged)).toContain('src') + expect(text(unstaged)).toContain('Unstaged') + expect(text(unstaged)).toContain('git.ts') + expect(text(untracked)).toContain('Untracked') + expect(text(untracked)).toContain('notes.md') + + // Status letters: M for a modification, ? for an untracked entry. + expect([...rows(element)].map(row => text(row.querySelector('.git-changes__status')))) + .toEqual(['M', 'M', '?']) + }) + + it('reports an unusable repository instead of rendering an empty list', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ available: false, branch: null, reason: 'not_repository' })), + diff: vi.fn(async () => diffPayload()), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-unavailable"]')).toBeTruthy()) + expect(text(element.querySelector('[data-testid="git-changes-unavailable"]'))) + .toContain('not a Git repository') + expect(rows(element)).toHaveLength(0) + expect(element.querySelector('[data-testid="git-changes-clean"]')).toBeNull() + }) + + it('separates "git missing" from "not a repository"', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ available: false, reason: 'git_unavailable' })), + diff: vi.fn(async () => diffPayload()), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-unavailable"]')).toBeTruthy()) + expect(text(element.querySelector('[data-testid="git-changes-unavailable"]'))) + .toContain('Git is not available') + }) + + it('says the tree is clean when a usable repository has no changes', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ clean: true, files: [] })), + diff: vi.fn(async () => diffPayload()), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-clean"]')).toBeTruthy()) + expect(rows(element)).toHaveLength(0) + }) + + it('requests the clicked file diff with its group and renders +/- lines', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ + files: [{ path: 'src/App.vue', status: 'modified', staged: true }], + })), + diff: vi.fn(async () => diffPayload({ + diff: [ + 'diff --git a/src/App.vue b/src/App.vue', + '--- a/src/App.vue', + '+++ b/src/App.vue', + '@@ -1,2 +1,2 @@', + ' unchanged', + '-old line', + '+new line', + '', + ].join('\n'), + additions: 1, + deletions: 1, + })), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(rows(element)).toHaveLength(1)) + rows(element)[0].click() + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-diff-code"]')).toBeTruthy()) + expect(access.diff).toHaveBeenCalledWith(expect.objectContaining({ + path: 'src/App.vue', + staged: true, + workspaceId: 'ws-1', + signal: expect.anything(), + })) + expect(text(element.querySelector('.git-changes__diff-line.is-add'))).toBe('+new line') + expect(text(element.querySelector('.git-changes__diff-line.is-del'))).toBe('-old line') + expect(element.querySelectorAll('.git-changes__diff-line')).toHaveLength(7) + expect(text(element.querySelector('[data-testid="git-changes-diff"] .git-changes__badge'))).toBe('Staged') + }) + + it('keeps a binary diff readable and flags a truncated one', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload({ + files: [{ path: 'assets/logo.png', status: 'untracked', staged: false }], + })), + diff: vi.fn(async () => diffPayload({ binary: true, truncated: true })), + } + const element = mountPanel(access) + + await vi.waitFor(() => expect(rows(element)).toHaveLength(1)) + rows(element)[0].click() + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-diff-binary"]')).toBeTruthy()) + expect(text(element.querySelector('[data-testid="git-changes-diff-binary"]'))).toContain('Binary file') + expect(access.diff).toHaveBeenCalledWith(expect.objectContaining({ path: 'assets/logo.png', staged: false })) + }) + + it('offers a retry after a failed read and recovers on the second attempt', async () => { + const status = vi.fn() + .mockRejectedValueOnce(new Error('git.status failed: boom')) + .mockResolvedValueOnce(statusPayload({ files: [{ path: 'src/App.vue', status: 'modified', staged: false }] })) + const access: GitAccess = { status, diff: vi.fn(async () => diffPayload()) } + const element = mountPanel(access) + + await vi.waitFor(() => expect(element.querySelector('[data-testid="git-changes-error"]')).toBeTruthy()) + expect(text(element.querySelector('[data-testid="git-changes-error"]'))).toContain('boom') + + element.querySelector('.git-changes__retry')!.click() + await vi.waitFor(() => expect(rows(element)).toHaveLength(1)) + expect(element.querySelector('[data-testid="git-changes-error"]')).toBeNull() + expect(status).toHaveBeenCalledTimes(2) + }) + + it('does not read Git while the tab is hidden', async () => { + const access: GitAccess = { + status: vi.fn(async () => statusPayload()), + diff: vi.fn(async () => diffPayload()), + } + mountPanel(access, { workspaceId: 'ws-1', visible: false }) + + await nextTick() + expect(access.status).not.toHaveBeenCalled() + }) +}) + +describe('createGitChangesWorkbenchDefinition', () => { + const item: WorkbenchItem = { + id: 'git-changes', + kind: 'git-changes', + title: 'Source control', + scope: { type: 'session', id: 'session-a' }, + hostKind: 'dom', + retention: 'keep-alive', + payload: { workspaceId: 'ws-1' }, + } + const state: WorkbenchPanelRenderState = { + active: true, + visible: false, + hostAvailable: true, + nativeSurface: false, + runtimeState: {}, + } + + it('derives the header and the workspace from the item payload', () => { + const definition = createGitChangesWorkbenchDefinition( + { status: vi.fn(async () => statusPayload()), diff: vi.fn(async () => diffPayload()) }, + key => key, + ) + + expect(definition.kind).toBe('git-changes') + expect(definition.supports?.(item)).toBe(true) + expect(definition.getHeader?.(item, state)).toEqual({ + title: 'workbench.gitChanges.title', + subtitle: 'workbench.gitChanges.subtitle', + icon: 'fork', + }) + // `visible` from the host wins; without it the panel follows activate+host. + expect(definition.getProps?.(item, state)).toEqual({ workspaceId: 'ws-1', visible: false }) + expect(definition.getProps?.(item, { ...state, visible: undefined, active: true })).toEqual({ + workspaceId: 'ws-1', + visible: true, + }) + expect(definition.getProps?.({ ...item, payload: {} }, state)).toEqual({ workspaceId: null, visible: false }) + }) + + it('declines the item when no Git owner was injected', () => { + const definition = createGitChangesWorkbenchDefinition(null, key => key) + expect(definition.supports?.(item)).toBe(false) + }) + + it('ignores a non-string payload workspace', () => { + expect(gitChangesWorkspaceId({ ...item, payload: { workspaceId: 42 } })).toBeNull() + expect(gitChangesWorkspaceId({ ...item, payload: { workspaceId: '' } })).toBeNull() + expect(gitChangesWorkspaceId(item)).toBe('ws-1') + }) +}) diff --git a/opensquilla-webui/src/components/workbench/GitChangesPanel.vue b/opensquilla-webui/src/components/workbench/GitChangesPanel.vue new file mode 100644 index 0000000000..b6a2db64a0 --- /dev/null +++ b/opensquilla-webui/src/components/workbench/GitChangesPanel.vue @@ -0,0 +1,447 @@ + + + + + diff --git a/opensquilla-webui/src/components/workbench/gitChangesWorkbenchProvider.ts b/opensquilla-webui/src/components/workbench/gitChangesWorkbenchProvider.ts new file mode 100644 index 0000000000..7a5242aa8b --- /dev/null +++ b/opensquilla-webui/src/components/workbench/gitChangesWorkbenchProvider.ts @@ -0,0 +1,31 @@ +import type { GitAccess } from '@/modules/git' +import type { WorkbenchItem, WorkbenchPanelDefinition } from '@/workbench/types' +import GitChangesPanel from './GitChangesPanel.vue' + +/** + * The workspace a Git workbench tab reads. App.vue writes it into the item + * payload when the branch chip opens the tab, because a panel renders outside + * the chat route that owns the project selection. + */ +export function gitChangesWorkspaceId(item: WorkbenchItem): string | null { + const value = item.payload.workspaceId + return typeof value === 'string' && value ? value : null +} + +export function createGitChangesWorkbenchDefinition( + access: GitAccess | null, + t: (key: string) => string, +): WorkbenchPanelDefinition { + return { + kind: 'git-changes', + component: GitChangesPanel, + // Without a Git owner the tab has nothing truthful to show, so the host + // falls back to its empty state instead of an always-empty panel. + supports: item => item.kind === 'git-changes' && Boolean(access), + getHeader: () => ({ title: t('workbench.gitChanges.title'), subtitle: t('workbench.gitChanges.subtitle'), icon: 'fork' }), + getProps: (item: WorkbenchItem, state) => ({ + workspaceId: gitChangesWorkspaceId(item), + visible: state.visible ?? (state.active && state.hostAvailable), + }), + } +} diff --git a/opensquilla-webui/src/composables/useGitStatus.test.ts b/opensquilla-webui/src/composables/useGitStatus.test.ts new file mode 100644 index 0000000000..06c0121120 --- /dev/null +++ b/opensquilla-webui/src/composables/useGitStatus.test.ts @@ -0,0 +1,133 @@ +import { effectScope, shallowRef } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { useGitStatus } from './useGitStatus' +import type { GitAccess, GitStatusPayload } from '@/modules/git' + +function payload(branch: string, overrides: Partial = {}): GitStatusPayload { + return { + available: true, + workspacePath: `/repos/${branch}`, + branch, + detached: false, + unborn: false, + headOid: 'a'.repeat(40), + ahead: 0, + behind: 0, + clean: true, + files: [], + reason: null, + ...overrides, + } +} + +function deferredRead() { + let resolve!: (value: GitStatusPayload) => void + let reject!: (cause: unknown) => void + const promise = new Promise((settle, fail) => { resolve = settle; reject = fail }) + return { promise, resolve, reject } +} + +function mount(access: GitAccess, workspaceId: string | null) { + const scope = effectScope() + const state = scope.run(() => { + const accessRef = shallowRef(access) + const workspaceRef = shallowRef(workspaceId) + return { accessRef, workspaceRef, git: useGitStatus({ access: accessRef, workspaceId: workspaceRef }) } + }) + if (!state) throw new Error('effect scope did not run') + return { scope, ...state } +} + +describe('useGitStatus', () => { + it('reports the branch, dirty count and HEAD label', async () => { + const access: GitAccess = { + status: vi.fn(async () => payload('main', { + clean: false, + files: [ + { path: 'src/a.ts', status: 'modified', staged: false }, + { path: 'src/b.ts', status: 'untracked', staged: false }, + ], + })), + diff: vi.fn(), + } + const { scope, git } = mount(access, null) + await vi.waitFor(() => expect(git.status.value?.branch).toBe('main')) + expect(git.loading.value).toBe(false) + expect(git.error.value).toBeNull() + expect(git.branchLabel.value).toBe('main') + expect(git.dirtyCount.value).toBe(2) + + const detachedPayload = payload('', { branch: null, detached: true, headOid: '0123456789abcdef' }) + const detached = mount({ status: vi.fn(async () => detachedPayload), diff: vi.fn() }, null) + await vi.waitFor(() => expect(detached.git.status.value?.detached).toBe(true)) + expect(detached.git.branchLabel.value).toBe('HEAD@0123456') + + const unbornPayload = payload('', { branch: null, unborn: true, headOid: null, clean: false }) + const unborn = mount({ status: vi.fn(async () => unbornPayload), diff: vi.fn() }, null) + await vi.waitFor(() => expect(unborn.git.status.value?.unborn).toBe(true)) + expect(unborn.git.branchLabel.value).toBe('') + + detached.scope.stop() + unborn.scope.stop() + scope.stop() + }) + + it('drops a late response for a superseded workspace', async () => { + const first = deferredRead() + const second = deferredRead() + const status = vi.fn((options?: { workspaceId?: string | null }) => ( + options?.workspaceId === 'workspace-b' ? second.promise : first.promise + )) + const { scope, workspaceRef, git } = mount({ status } as unknown as GitAccess, 'workspace-a') + expect(status).toHaveBeenCalledTimes(1) + expect(status.mock.calls[0][0]).toMatchObject({ workspaceId: 'workspace-a' }) + + workspaceRef.value = 'workspace-b' + await vi.waitFor(() => expect(status).toHaveBeenCalledTimes(2)) + // Switching repositories must not keep rendering the previous one. + expect(git.status.value).toBeNull() + + second.resolve(payload('release')) + await vi.waitFor(() => expect(git.status.value?.branch).toBe('release')) + first.resolve(payload('main')) + await Promise.resolve() + expect(git.status.value?.branch).toBe('release') + scope.stop() + }) + + it('clears the previous repository when the next read fails', async () => { + let fail = false + const status = vi.fn(async () => { + if (fail) throw new Error('git unavailable') + return payload('main') + }) + const { scope, workspaceRef, git } = mount({ status } as unknown as GitAccess, 'workspace-a') + await vi.waitFor(() => expect(git.status.value?.branch).toBe('main')) + + fail = true + workspaceRef.value = 'workspace-b' + await vi.waitFor(() => expect(git.error.value).toBe('git unavailable')) + expect(git.status.value).toBeNull() + expect(git.branchLabel.value).toBe('') + expect(git.dirtyCount.value).toBe(0) + expect(git.loading.value).toBe(false) + scope.stop() + }) + + it('skips reads while inactive and reloads when reactivated', async () => { + const status = vi.fn(async () => payload('main')) + const scope = effectScope() + const state = scope.run(() => { + const accessRef = shallowRef({ status } as unknown as GitAccess) + const workspaceRef = shallowRef('workspace-a') + const activeRef = shallowRef(false) + return { activeRef, git: useGitStatus({ access: accessRef, workspaceId: workspaceRef, active: activeRef }) } + }) + if (!state) throw new Error('effect scope did not run') + expect(status).not.toHaveBeenCalled() + state.activeRef.value = true + await vi.waitFor(() => expect(state.git.status.value?.branch).toBe('main')) + expect(status).toHaveBeenCalledTimes(1) + scope.stop() + }) +}) diff --git a/opensquilla-webui/src/composables/useGitStatus.ts b/opensquilla-webui/src/composables/useGitStatus.ts new file mode 100644 index 0000000000..f7309e3ff6 --- /dev/null +++ b/opensquilla-webui/src/composables/useGitStatus.ts @@ -0,0 +1,122 @@ +import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue' +import type { GitAccess, GitStatusPayload } from '@/modules/git' + +type ReadRef = Ref | ComputedRef + +export interface UseGitStatusOptions { + access: ReadRef + /** Active project workspace, or null for the session default working directory. */ + workspaceId: ReadRef + /** Optional visibility gate: a hidden panel never reads Git on its own. */ + active?: ReadRef +} + +export interface GitStatusHandle { + status: Ref + loading: Ref + error: Ref + /** Compact label for the topbar branch button. */ + branchLabel: ComputedRef + /** Number of changed entries; not a line count. */ + dirtyCount: ComputedRef + /** Read the current repository again, for example after a checkout. */ + refresh: () => Promise +} + +function failureMessage(cause: unknown): string { + if (cause instanceof Error && cause.message) return cause.message + return typeof cause === 'string' && cause ? cause : 'git.status failed' +} + +/** + * Read-only Git status for one workspace. Reads happen on workspace change and + * on explicit `refresh()` only; nothing polls. + */ +export function useGitStatus(options: UseGitStatusOptions): GitStatusHandle { + const status = ref(null) + const loading = ref(false) + const error = ref(null) + /** + * Monotonic fence. A response that belongs to a superseded read is dropped + * instead of being applied, so switching projects cannot cross repositories. + */ + let serial = 0 + let controller: AbortController | null = null + /** Scope of the last read that started; drives clearing stale repository data. */ + let loadedScope: string | null | undefined + + function cancel(): void { + serial += 1 + controller?.abort() + controller = null + } + + async function read(): Promise { + cancel() + const current = serial + const workspaceId = options.workspaceId.value ?? null + // Different repository: drop the previous file list before the first await, + // so neither a slow response nor a failure can show another repo's files. + if (loadedScope !== workspaceId) { + loadedScope = workspaceId + status.value = null + error.value = null + } + const access = options.access.value + if (!access || options.active?.value === false) { + loading.value = false + return + } + const abort = new AbortController() + controller = abort + loading.value = true + error.value = null + try { + const payload = await access.status({ workspaceId, signal: abort.signal }) + if (current !== serial) return + status.value = payload + error.value = null + } catch (cause) { + if (current !== serial) return + // A failed read leaves this workspace unknown, never the previous one. + status.value = null + error.value = failureMessage(cause) + } finally { + if (current === serial) { + loading.value = false + if (controller === abort) controller = null + } + } + } + + watch( + [ + () => options.workspaceId.value, + () => options.access.value, + () => options.active?.value ?? true, + ], + () => { void read() }, + { immediate: true }, + ) + + onScopeDispose(cancel) + + const branchLabel = computed(() => { + const current = status.value + if (!current) return '' + if (current.detached) return current.headOid ? `HEAD@${current.headOid.slice(0, 7)}` : 'HEAD' + if (current.unborn && !current.branch) return '' + return current.branch ?? '' + }) + + const dirtyCount = computed(() => status.value?.files.length ?? 0) + + return { + status, + loading, + error, + branchLabel, + dirtyCount, + refresh: read, + } +} diff --git a/opensquilla-webui/src/contracts/generated/v4/gitDiff.ts b/opensquilla-webui/src/contracts/generated/v4/gitDiff.ts new file mode 100644 index 0000000000..694a8e3c9b --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitDiff.ts @@ -0,0 +1,85 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: d79431e0aa9a75cd290b2ac65fc9e5351111a9f8bb833e3a7c94ce4972ca4e84 + +export interface OpenSquillaGitDiffContract { + request?: RequestFrame; + response?: ResponseFrame; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "RequestFrame". + */ +export interface RequestFrame { + type: "req"; + id: string; + method: "git.diff"; + params?: Params; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "Params". + */ +export interface Params { + /** + * Path relative to the repository root. + */ + path: string; + /** + * Project workspace id; null or omitted means the current session default working directory. + */ + workspaceId?: string | null; + staged?: boolean; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "ResponseFrame". + */ +export interface ResponseFrame { + type: "res"; + id: string; + ok: boolean; + payload?: Result; + error?: RpcError; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "Result". + */ +export interface Result { + diff: Diff; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "Diff". + */ +export interface Diff { + path: string; + /** + * Unified diff text. + */ + diff: string; + binary: boolean; + /** + * Whether an over-long diff was truncated. + */ + truncated: boolean; + additions: number; + deletions: number; +} +/** + * This interface was referenced by `OpenSquillaGitDiffContract`'s JSON-Schema + * via the `definition` "RpcError". + */ +export interface RpcError { + [k: string]: unknown; +} + +export const GIT_DIFF_CONTRACT_KIND = "query" as const +export const GIT_DIFF_METHOD = "git.diff" as const +export const GIT_DIFF_SCOPE = "operator.read" as const +export const GIT_DIFF_IDEMPOTENCY = "read-only" as const +export const GIT_DIFF_TIMEOUT = {"policy":"transport"} as const +export const GIT_DIFF_CAPABILITY = {"kind":"method-availability","name":"git.diff"} as const +export const GIT_DIFF_ERRORS = [{"code":"OWNER_REQUIRED"},{"code":"INVALID_PARAMS"},{"code":"GIT_UNAVAILABLE"},{"code":"INTERNAL_ERROR"}] as const diff --git a/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.d.mts new file mode 100644 index 0000000000..930511edc3 --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.d.mts @@ -0,0 +1,10 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: d79431e0aa9a75cd290b2ac65fc9e5351111a9f8bb833e3a7c94ce4972ca4e84 + +export interface ContractValidator { + (value: unknown): boolean + errors?: readonly unknown[] | null +} + +export const validateParams: ContractValidator +export const validateResult: ContractValidator diff --git a/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.mjs new file mode 100644 index 0000000000..a9681e9859 --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitDiffValidators.mjs @@ -0,0 +1,4 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: d79431e0aa9a75cd290b2ac65fc9e5351111a9f8bb833e3a7c94ce4972ca4e84 + +"use strict";export const validateParams = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:Params","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params"};const schema34 = {"type":"object","additionalProperties":false,"required":["path"],"properties":{"path":{"description":"Path relative to the repository root.","type":"string"},"workspaceId":{"description":"Project workspace id; null or omitted means the current session default working directory.","type":["string","null"]},"staged":{"type":"boolean"}}};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:Params" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.path === undefined){const err0 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/required",keyword:"required",params:{missingProperty: "path"},message:"must have required property '"+"path"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}for(const key0 in data){if(!(((key0 === "path") || (key0 === "workspaceId")) || (key0 === "staged"))){const err1 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.path !== undefined){if(typeof data.path !== "string"){const err2 = {instancePath:instancePath+"/path",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.workspaceId !== undefined){let data1 = data.workspaceId;if((typeof data1 !== "string") && (data1 !== null)){const err3 = {instancePath:instancePath+"/workspaceId",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/properties/workspaceId/type",keyword:"type",params:{type: schema34.properties.workspaceId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.staged !== undefined){if(typeof data.staged !== "boolean"){const err4 = {instancePath:instancePath+"/staged",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/properties/staged/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}else {const err5 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Params/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};export const validateResult = validate28;const schema40 = {"$id":"urn:opensquilla:contract:v4:Result","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/git/git-diff.schema.json#/$defs/Result"};const schema36 = {"type":"object","additionalProperties":false,"required":["diff"],"properties":{"diff":{"$ref":"#/$defs/Diff"}}};const schema37 = {"type":"object","additionalProperties":false,"required":["path","diff","binary","truncated","additions","deletions"],"properties":{"path":{"type":"string"},"diff":{"description":"Unified diff text.","type":"string"},"binary":{"type":"boolean"},"truncated":{"description":"Whether an over-long diff was truncated.","type":"boolean"},"additions":{"type":"integer","minimum":0},"deletions":{"type":"integer","minimum":0}}};function validate29(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate29.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.diff === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "diff"},message:"must have required property '"+"diff"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}for(const key0 in data){if(!(key0 === "diff")){const err1 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.diff !== undefined){let data0 = data.diff;if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.path === undefined){const err2 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "path"},message:"must have required property '"+"path"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data0.diff === undefined){const err3 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "diff"},message:"must have required property '"+"diff"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data0.binary === undefined){const err4 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "binary"},message:"must have required property '"+"binary"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data0.truncated === undefined){const err5 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "truncated"},message:"must have required property '"+"truncated"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data0.additions === undefined){const err6 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "additions"},message:"must have required property '"+"additions"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data0.deletions === undefined){const err7 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/required",keyword:"required",params:{missingProperty: "deletions"},message:"must have required property '"+"deletions"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(const key1 in data0){if(!((((((key1 === "path") || (key1 === "diff")) || (key1 === "binary")) || (key1 === "truncated")) || (key1 === "additions")) || (key1 === "deletions"))){const err8 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data0.path !== undefined){if(typeof data0.path !== "string"){const err9 = {instancePath:instancePath+"/diff/path",schemaPath:"#/$defs/Diff/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data0.diff !== undefined){if(typeof data0.diff !== "string"){const err10 = {instancePath:instancePath+"/diff/diff",schemaPath:"#/$defs/Diff/properties/diff/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data0.binary !== undefined){if(typeof data0.binary !== "boolean"){const err11 = {instancePath:instancePath+"/diff/binary",schemaPath:"#/$defs/Diff/properties/binary/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data0.truncated !== undefined){if(typeof data0.truncated !== "boolean"){const err12 = {instancePath:instancePath+"/diff/truncated",schemaPath:"#/$defs/Diff/properties/truncated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data0.additions !== undefined){let data5 = data0.additions;if(!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))){const err13 = {instancePath:instancePath+"/diff/additions",schemaPath:"#/$defs/Diff/properties/additions/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 0 || isNaN(data5)){const err14 = {instancePath:instancePath+"/diff/additions",schemaPath:"#/$defs/Diff/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}if(data0.deletions !== undefined){let data6 = data0.deletions;if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){const err15 = {instancePath:instancePath+"/diff/deletions",schemaPath:"#/$defs/Diff/properties/deletions/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err16 = {instancePath:instancePath+"/diff/deletions",schemaPath:"#/$defs/Diff/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}}else {const err17 = {instancePath:instancePath+"/diff",schemaPath:"#/$defs/Diff/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate29.errors = vErrors;return errors === 0;}validate29.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:Result" */;let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate29(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate29.errors : vErrors.concat(validate29.errors);errors = vErrors.length;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; diff --git a/opensquilla-webui/src/contracts/generated/v4/gitStatus.ts b/opensquilla-webui/src/contracts/generated/v4/gitStatus.ts new file mode 100644 index 0000000000..5859641d64 --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitStatus.ts @@ -0,0 +1,107 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: 6fd00129051d12d6745e883c72d96063da4107b8d0c63e26a193a05ec1a28ffd + +export interface OpenSquillaGitStatusContract { + request?: RequestFrame; + response?: ResponseFrame; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "RequestFrame". + */ +export interface RequestFrame { + type: "req"; + id: string; + method: "git.status"; + params?: Params; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "Params". + */ +export interface Params { + /** + * Project workspace id; null or omitted means the current session default working directory. + */ + workspaceId?: string | null; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "ResponseFrame". + */ +export interface ResponseFrame { + type: "res"; + id: string; + ok: boolean; + payload?: Result; + error?: RpcError; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "Result". + */ +export interface Result { + status: Status; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "Status". + */ +export interface Status { + /** + * Whether the directory is a usable git repository. + */ + available: boolean; + /** + * Resolved absolute path of the repository. + */ + workspacePath: string | null; + /** + * Branch name; null while detached. + */ + branch: string | null; + detached: boolean; + /** + * Branch without any commit yet. + */ + unborn: boolean; + /** + * Full HEAD commit id. + */ + headOid: string | null; + ahead: number; + behind: number; + clean: boolean; + files: ChangedFile[]; + /** + * Why the repository is unavailable, such as not_repository or git_unavailable. + */ + reason: string | null; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "ChangedFile". + */ +export interface ChangedFile { + path: string; + status: "modified" | "added" | "deleted" | "renamed" | "untracked" | "conflicted"; + staged: boolean; + previousPath?: string | null; +} +/** + * This interface was referenced by `OpenSquillaGitStatusContract`'s JSON-Schema + * via the `definition` "RpcError". + */ +export interface RpcError { + [k: string]: unknown; +} + +export const GIT_STATUS_CONTRACT_KIND = "query" as const +export const GIT_STATUS_METHOD = "git.status" as const +export const GIT_STATUS_SCOPE = "operator.read" as const +export const GIT_STATUS_IDEMPOTENCY = "read-only" as const +export const GIT_STATUS_TIMEOUT = {"policy":"transport"} as const +export const GIT_STATUS_CAPABILITY = {"kind":"method-availability","name":"git.status"} as const +export const GIT_STATUS_ERRORS = [{"code":"OWNER_REQUIRED"},{"code":"INVALID_PARAMS"},{"code":"GIT_UNAVAILABLE"},{"code":"INTERNAL_ERROR"}] as const diff --git a/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.d.mts new file mode 100644 index 0000000000..b101a80864 --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.d.mts @@ -0,0 +1,10 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: 6fd00129051d12d6745e883c72d96063da4107b8d0c63e26a193a05ec1a28ffd + +export interface ContractValidator { + (value: unknown): boolean + errors?: readonly unknown[] | null +} + +export const validateParams: ContractValidator +export const validateResult: ContractValidator diff --git a/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.mjs new file mode 100644 index 0000000000..bb5688633a --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/gitStatusValidators.mjs @@ -0,0 +1,4 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: 6fd00129051d12d6745e883c72d96063da4107b8d0c63e26a193a05ec1a28ffd + +"use strict";export const validateParams = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:Params","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json#/$defs/Params"};const schema34 = {"type":"object","additionalProperties":false,"properties":{"workspaceId":{"description":"Project workspace id; null or omitted means the current session default working directory.","type":["string","null"]}}};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:Params" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){for(const key0 in data){if(!(key0 === "workspaceId")){const err0 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json#/$defs/Params/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.workspaceId !== undefined){let data0 = data.workspaceId;if((typeof data0 !== "string") && (data0 !== null)){const err1 = {instancePath:instancePath+"/workspaceId",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json#/$defs/Params/properties/workspaceId/type",keyword:"type",params:{type: schema34.properties.workspaceId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}else {const err2 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json#/$defs/Params/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};export const validateResult = validate30;const schema41 = {"$id":"urn:opensquilla:contract:v4:Result","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/git/git-status.schema.json#/$defs/Result"};const schema36 = {"type":"object","additionalProperties":false,"required":["status"],"properties":{"status":{"$ref":"#/$defs/Status"}}};const schema37 = {"type":"object","additionalProperties":false,"required":["available","workspacePath","branch","detached","unborn","headOid","ahead","behind","clean","files","reason"],"properties":{"available":{"description":"Whether the directory is a usable git repository.","type":"boolean"},"workspacePath":{"description":"Resolved absolute path of the repository.","type":["string","null"]},"branch":{"description":"Branch name; null while detached.","type":["string","null"]},"detached":{"type":"boolean"},"unborn":{"description":"Branch without any commit yet.","type":"boolean"},"headOid":{"description":"Full HEAD commit id.","type":["string","null"]},"ahead":{"type":"integer","minimum":0},"behind":{"type":"integer","minimum":0},"clean":{"type":"boolean"},"files":{"type":"array","items":{"$ref":"#/$defs/ChangedFile"}},"reason":{"description":"Why the repository is unavailable, such as not_repository or git_unavailable.","type":["string","null"]}}};const schema38 = {"type":"object","additionalProperties":false,"required":["path","status","staged"],"properties":{"path":{"type":"string"},"status":{"enum":["modified","added","deleted","renamed","untracked","conflicted"]},"staged":{"type":"boolean"},"previousPath":{"type":["string","null"]}}};const func1 = Object.prototype.hasOwnProperty;function validate26(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate26.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.available === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "available"},message:"must have required property '"+"available"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.workspacePath === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "workspacePath"},message:"must have required property '"+"workspacePath"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.branch === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "branch"},message:"must have required property '"+"branch"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.detached === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detached"},message:"must have required property '"+"detached"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.unborn === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "unborn"},message:"must have required property '"+"unborn"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.headOid === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "headOid"},message:"must have required property '"+"headOid"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.ahead === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "ahead"},message:"must have required property '"+"ahead"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.behind === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "behind"},message:"must have required property '"+"behind"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.clean === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "clean"},message:"must have required property '"+"clean"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.files === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.reason === undefined){const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}for(const key0 in data){if(!(func1.call(schema37.properties, key0))){const err11 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.available !== undefined){if(typeof data.available !== "boolean"){const err12 = {instancePath:instancePath+"/available",schemaPath:"#/properties/available/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.workspacePath !== undefined){let data1 = data.workspacePath;if((typeof data1 !== "string") && (data1 !== null)){const err13 = {instancePath:instancePath+"/workspacePath",schemaPath:"#/properties/workspacePath/type",keyword:"type",params:{type: schema37.properties.workspacePath.type},message:"must be string,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.branch !== undefined){let data2 = data.branch;if((typeof data2 !== "string") && (data2 !== null)){const err14 = {instancePath:instancePath+"/branch",schemaPath:"#/properties/branch/type",keyword:"type",params:{type: schema37.properties.branch.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.detached !== undefined){if(typeof data.detached !== "boolean"){const err15 = {instancePath:instancePath+"/detached",schemaPath:"#/properties/detached/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.unborn !== undefined){if(typeof data.unborn !== "boolean"){const err16 = {instancePath:instancePath+"/unborn",schemaPath:"#/properties/unborn/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.headOid !== undefined){let data5 = data.headOid;if((typeof data5 !== "string") && (data5 !== null)){const err17 = {instancePath:instancePath+"/headOid",schemaPath:"#/properties/headOid/type",keyword:"type",params:{type: schema37.properties.headOid.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.ahead !== undefined){let data6 = data.ahead;if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){const err18 = {instancePath:instancePath+"/ahead",schemaPath:"#/properties/ahead/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err19 = {instancePath:instancePath+"/ahead",schemaPath:"#/properties/ahead/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}if(data.behind !== undefined){let data7 = data.behind;if(!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))){const err20 = {instancePath:instancePath+"/behind",schemaPath:"#/properties/behind/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err21 = {instancePath:instancePath+"/behind",schemaPath:"#/properties/behind/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data.clean !== undefined){if(typeof data.clean !== "boolean"){const err22 = {instancePath:instancePath+"/clean",schemaPath:"#/properties/clean/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.files !== undefined){let data9 = data.files;if(Array.isArray(data9)){const len0 = data9.length;for(let i0=0; i0 { + it('splits staged, unstaged and untracked entries', () => { + const grouped = groupChangedFiles([ + entry('src/a.ts', 'modified', true), + entry('src/b.ts', 'modified', false), + entry('src/c.ts', 'untracked', false), + ]) + expect(grouped.staged.map(file => file.path)).toEqual(['src/a.ts']) + expect(grouped.unstaged.map(file => file.path)).toEqual(['src/b.ts']) + expect(grouped.untracked.map(file => file.path)).toEqual(['src/c.ts']) + }) + + it('keeps staged renames and unstaged conflicts out of the untracked bucket', () => { + const grouped = groupChangedFiles([ + entry('src/new.ts', 'renamed', true, 'src/old.ts'), + entry('src/conflict.ts', 'conflicted', false), + entry('src/added.ts', 'added', true), + ]) + expect(grouped.untracked).toEqual([]) + expect(grouped.staged.map(file => file.status)).toEqual(['renamed', 'added']) + expect(grouped.unstaged.map(file => file.status)).toEqual(['conflicted']) + }) + + it('reports an untracked entry only once, even when Git marks it staged', () => { + const untracked = entry('src/x.ts', 'untracked', true) + expect(groupChangedFiles([untracked])).toEqual({ staged: [], unstaged: [], untracked: [untracked] }) + }) + + it('returns empty groups for a clean tree', () => { + expect(groupChangedFiles([])).toEqual({ staged: [], unstaged: [], untracked: [] }) + }) +}) diff --git a/opensquilla-webui/src/modules/git.ts b/opensquilla-webui/src/modules/git.ts new file mode 100644 index 0000000000..ebe959929a --- /dev/null +++ b/opensquilla-webui/src/modules/git.ts @@ -0,0 +1,101 @@ +import { hasInjectionContext, inject } from 'vue' +import type { InjectionKey } from 'vue' + +/** + * Read-only Git presentation model for the branch control and the workbench + * source-control panel. + * + * These shapes mirror the `git.status` / `git.diff` wire Contracts, but the + * generated Contract modules stay inside the Gateway Adapter. The Adapter + * returns these types, so a wire change fails the build instead of silently + * drifting from what the UI renders. + */ +export type GitFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'conflicted' + +export interface GitChangedFile { + path: string + status: GitFileStatus + staged: boolean + previousPath?: string | null +} + +export interface GitStatusPayload { + /** Whether the directory is a usable git repository. */ + available: boolean + workspacePath: string | null + /** Branch name; null while detached. */ + branch: string | null + detached: boolean + /** Branch without any commit yet. */ + unborn: boolean + headOid: string | null + ahead: number + behind: number + clean: boolean + files: GitChangedFile[] + /** Why the repository is unavailable, such as `not_repository` or `git_unavailable`. */ + reason: string | null +} + +export interface GitDiffPayload { + path: string + /** Unified diff text. */ + diff: string + binary: boolean + /** Whether an over-long diff was truncated. */ + truncated: boolean + additions: number + deletions: number +} + +export interface GitStatusReadOptions { + workspaceId?: string | null + signal?: AbortSignal +} + +export interface GitDiffReadInput { + /** Path relative to the repository root. */ + path: string + workspaceId?: string | null + staged?: boolean + signal?: AbortSignal +} + +/** Owner-only read surface; every method is a query, never a mutation. */ +export interface GitAccess { + status(options?: GitStatusReadOptions): Promise + diff(input: GitDiffReadInput): Promise +} + +export const GIT_ACCESS_KEY: InjectionKey = Symbol('GitAccess') + +/** Absent outside the app shell (unit tests, isolated panels) rather than throwing. */ +export function useGitAccess(): GitAccess | null { + return hasInjectionContext() ? inject(GIT_ACCESS_KEY, null) : null +} + +export interface GroupedChangedFiles { + /** Index changes: `staged` is true and the file is tracked. */ + staged: GitChangedFile[] + /** Working-tree changes that are not staged yet. */ + unstaged: GitChangedFile[] + /** Files Git does not track yet; never reported as an unstaged change. */ + untracked: GitChangedFile[] +} + +/** Split working-tree changes for the branch panel; `staged` alone is not enough. */ +export function groupChangedFiles(files: readonly GitChangedFile[]): GroupedChangedFiles { + const grouped: GroupedChangedFiles = { staged: [], unstaged: [], untracked: [] } + for (const file of files) { + if (file.status === 'untracked') grouped.untracked.push(file) + else if (file.staged) grouped.staged.push(file) + else grouped.unstaged.push(file) + } + return grouped +} diff --git a/opensquilla-webui/src/utils/icons.ts b/opensquilla-webui/src/utils/icons.ts index 93b7326fbc..498bec5908 100644 --- a/opensquilla-webui/src/utils/icons.ts +++ b/opensquilla-webui/src/utils/icons.ts @@ -7,7 +7,7 @@ export type IconName = | 'send' | 'play' | 'stop' | 'paperclip' | 'plus' | 'share' | 'trash' | 'refresh' | 'download' | 'save' | 'menu' | 'moreHorizontal' | 'user' | 'search' | 'eye' | 'eye-off' | 'edit' | 'info' | 'settings' | 'gear' | 'gauge' | 'router' | 'regenerate' - | 'pencil' | 'fork' | 'listChecks' | 'chevronDown' | 'chevronLeft' | 'chevronRight' | 'arrowUp' + | 'pencil' | 'fork' | 'gitBranch' | 'listChecks' | 'chevronDown' | 'chevronLeft' | 'chevronRight' | 'arrowUp' | 'expand' | 'collapse' | 'panel-left-open' | 'panel-left-close' | 'panel-right-open' | 'panel-right-close' | 'sidebar-visible' | 'sidebar-hidden' @@ -80,6 +80,11 @@ const ICONS: Record = { regenerate: { path: '', strokeWidth: 1.5 }, pencil: { path: '', strokeWidth: 1.5 }, fork: { path: '', strokeWidth: 1.5 }, + // Source-control branch glyph, distinct from `fork`, which stays the + // session-fork glyph. GitHub Octicon `git-branch-16` (MIT), drawn as a solid + // glyph: the shared wrapper paints outline-only, so the path restores + // its own fill and scales the 16-unit octicon grid onto the 24-unit grid. + gitBranch: { path: '' }, listChecks: { path: '', strokeWidth: 1.7 }, chevronDown:{ path: '', strokeWidth: 1.5 }, chevronLeft:{ path: '', strokeWidth: 1.5 }, diff --git a/opensquilla-webui/src/workbench/types.ts b/opensquilla-webui/src/workbench/types.ts index 148e8cb84d..1b2afd924a 100644 --- a/opensquilla-webui/src/workbench/types.ts +++ b/opensquilla-webui/src/workbench/types.ts @@ -22,6 +22,7 @@ export type WorkbenchPanelKind = | 'tool-file-history' | 'diff' | 'terminal' + | 'git-changes' | 'assistant-chat' export type WorkbenchRetention = 'dispose-on-suspend' | 'keep-alive' diff --git a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py index f910c3adcd..359deab9eb 100644 --- a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py +++ b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py @@ -1,6 +1,6 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# sources-sha256: 26369c7901169a8c8a4780bade9add5606a40168003d67ca927b5a8d075f9edc -# generator-sha256: 829c5afc1ad4481e06e232cd30f7746db621efafcc9c29f6dcb01e7eb3b79cad +# sources-sha256: cc4004d87e49fe1fa90917b94d07436846460a057f38edb29de74d94ac26b913 +# generator-sha256: 39ff9a0e523eba24c4e87b457ff16caf3832e21db8bfb37b808d805bb8e6d25b # ruff: noqa from dataclasses import dataclass @@ -215,6 +215,14 @@ from opensquilla.contracts.generated.v4.documents_publish import Request as _documents_publish_request_model from opensquilla.contracts.generated.v4.documents_publish import Response as _documents_publish_response_model from opensquilla.contracts.generated.v4.documents_publish import Result as _documents_publish_result_model +from opensquilla.contracts.generated.v4.git_diff import Params as _git_diff_params_model +from opensquilla.contracts.generated.v4.git_diff import RequestFrame as _git_diff_request_model +from opensquilla.contracts.generated.v4.git_diff import ResponseFrame as _git_diff_response_model +from opensquilla.contracts.generated.v4.git_diff import Result as _git_diff_result_model +from opensquilla.contracts.generated.v4.git_status import Params as _git_status_params_model +from opensquilla.contracts.generated.v4.git_status import RequestFrame as _git_status_request_model +from opensquilla.contracts.generated.v4.git_status import ResponseFrame as _git_status_response_model +from opensquilla.contracts.generated.v4.git_status import Result as _git_status_result_model from opensquilla.contracts.generated.v4.goals_capabilities import Params as _goals_capabilities_params_model from opensquilla.contracts.generated.v4.goals_capabilities import RequestFrame as _goals_capabilities_request_model from opensquilla.contracts.generated.v4.goals_capabilities import ResponseFrame as _goals_capabilities_response_model @@ -1572,6 +1580,38 @@ class GatewayEventContract: response_model=_cron_update_response_model, result_model=_cron_update_result_model, ), + 'git.diff': GatewayMethodContract( + name='git.diff', + kind='query', + scope='operator.read', + guest_allowed=False, + idempotency='read-only', + timeout={'policy': 'transport'}, + capability={'kind': 'method-availability', 'name': 'git.diff'}, + errors=({'code': 'OWNER_REQUIRED'}, {'code': 'INVALID_PARAMS'}, {'code': 'GIT_UNAVAILABLE'}, {'code': 'INTERNAL_ERROR'}), + protocol='opensquilla-websocket-json', + wire_version=4, + request_model=_git_diff_request_model, + params_model=_git_diff_params_model, + response_model=_git_diff_response_model, + result_model=_git_diff_result_model, + ), + 'git.status': GatewayMethodContract( + name='git.status', + kind='query', + scope='operator.read', + guest_allowed=False, + idempotency='read-only', + timeout={'policy': 'transport'}, + capability={'kind': 'method-availability', 'name': 'git.status'}, + errors=({'code': 'OWNER_REQUIRED'}, {'code': 'INVALID_PARAMS'}, {'code': 'GIT_UNAVAILABLE'}, {'code': 'INTERNAL_ERROR'}), + protocol='opensquilla-websocket-json', + wire_version=4, + request_model=_git_status_request_model, + params_model=_git_status_params_model, + response_model=_git_status_response_model, + result_model=_git_status_result_model, + ), 'goals.capabilities': GatewayMethodContract( name='goals.capabilities', kind='query', diff --git a/src/opensquilla/contracts/generated/v4/git_diff.py b/src/opensquilla/contracts/generated/v4/git_diff.py new file mode 100644 index 0000000000..90e16f5595 --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/git_diff.py @@ -0,0 +1,110 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: d79431e0aa9a75cd290b2ac65fc9e5351111a9f8bb833e3a7c94ce4972ca4e84 +# ruff: noqa + +# generated by datamodel-codegen: +# filename: git-diff.schema.json + +from __future__ import annotations + +from typing import Any, Annotated, Literal + +from pydantic import ( + BeforeValidator, + WithJsonSchema, + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictStr, +) + + +def _validate_json_number(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value == value and abs(value) != float('inf'): + return value + raise ValueError('expected a finite JSON number') + + +def _validate_json_integer(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value.is_integer(): + return value + raise ValueError('expected an integral JSON number') + + +_JsonInteger = Annotated[ + int | float, + BeforeValidator(_validate_json_integer), + WithJsonSchema({'type': 'integer'}), +] + +class Params(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + path: StrictStr = Field(..., description='Path relative to the repository root.') + workspaceId: StrictStr | None = Field( + None, + description='Project workspace id; null or omitted means the current session default working directory.', + ) + staged: StrictBool | None = None + + +class Diff(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + path: StrictStr + diff: StrictStr = Field(..., description='Unified diff text.') + binary: StrictBool + truncated: StrictBool = Field( + ..., description='Whether an over-long diff was truncated.' + ) + additions: _JsonInteger = Field(..., ge=0) + deletions: _JsonInteger = Field(..., ge=0) + + +class Result(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + diff: Diff + + +class RpcError(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + + +class RequestFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['req'] + id: StrictStr + method: Literal['git.diff'] + params: Params | None = None + + +class ResponseFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['res'] + id: StrictStr + ok: StrictBool + payload: Result | None = None + error: RpcError | None = None + + +class OpensquillaGitDiffContract(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + request: RequestFrame | None = None + response: ResponseFrame | None = None diff --git a/src/opensquilla/contracts/generated/v4/git_diff_metadata.py b/src/opensquilla/contracts/generated/v4/git_diff_metadata.py new file mode 100644 index 0000000000..11f83306b4 --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/git_diff_metadata.py @@ -0,0 +1,13 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: d79431e0aa9a75cd290b2ac65fc9e5351111a9f8bb833e3a7c94ce4972ca4e84 +# ruff: noqa + +from typing import Final + +GIT_DIFF_CONTRACT_KIND: Final = 'query' +GIT_DIFF_METHOD: Final = 'git.diff' +GIT_DIFF_SCOPE: Final = 'operator.read' +GIT_DIFF_IDEMPOTENCY: Final = 'read-only' +GIT_DIFF_TIMEOUT: Final = {'policy': 'transport'} +GIT_DIFF_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'git.diff'} +GIT_DIFF_ERRORS: Final = [{'code': 'OWNER_REQUIRED'}, {'code': 'INVALID_PARAMS'}, {'code': 'GIT_UNAVAILABLE'}, {'code': 'INTERNAL_ERROR'}] diff --git a/src/opensquilla/contracts/generated/v4/git_status.py b/src/opensquilla/contracts/generated/v4/git_status.py new file mode 100644 index 0000000000..466001b0e4 --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/git_status.py @@ -0,0 +1,140 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: 6fd00129051d12d6745e883c72d96063da4107b8d0c63e26a193a05ec1a28ffd +# ruff: noqa + +# generated by datamodel-codegen: +# filename: git-status.schema.json + +from __future__ import annotations + +from enum import Enum +from typing import Any, Annotated, Literal + +from pydantic import ( + BeforeValidator, + WithJsonSchema, + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictStr, +) + + +def _validate_json_number(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value == value and abs(value) != float('inf'): + return value + raise ValueError('expected a finite JSON number') + + +def _validate_json_integer(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value.is_integer(): + return value + raise ValueError('expected an integral JSON number') + + +_JsonInteger = Annotated[ + int | float, + BeforeValidator(_validate_json_integer), + WithJsonSchema({'type': 'integer'}), +] + +class Params(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + workspaceId: StrictStr | None = Field( + None, + description='Project workspace id; null or omitted means the current session default working directory.', + ) + + +class Status(Enum): + modified = 'modified' + added = 'added' + deleted = 'deleted' + renamed = 'renamed' + untracked = 'untracked' + conflicted = 'conflicted' + + +class ChangedFile(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + path: StrictStr + status: Status + staged: StrictBool + previousPath: StrictStr | None = None + + +class Status1(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + available: StrictBool = Field( + ..., description='Whether the directory is a usable git repository.' + ) + workspacePath: StrictStr | None = Field( + ..., description='Resolved absolute path of the repository.' + ) + branch: StrictStr | None = Field( + ..., description='Branch name; null while detached.' + ) + detached: StrictBool + unborn: StrictBool = Field(..., description='Branch without any commit yet.') + headOid: StrictStr | None = Field(..., description='Full HEAD commit id.') + ahead: _JsonInteger = Field(..., ge=0) + behind: _JsonInteger = Field(..., ge=0) + clean: StrictBool + files: list[ChangedFile] + reason: StrictStr | None = Field( + ..., + description='Why the repository is unavailable, such as not_repository or git_unavailable.', + ) + + +class Result(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: Status1 + + +class RpcError(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + + +class RequestFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['req'] + id: StrictStr + method: Literal['git.status'] + params: Params | None = None + + +class ResponseFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['res'] + id: StrictStr + ok: StrictBool + payload: Result | None = None + error: RpcError | None = None + + +class OpensquillaGitStatusContract(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + request: RequestFrame | None = None + response: ResponseFrame | None = None diff --git a/src/opensquilla/contracts/generated/v4/git_status_metadata.py b/src/opensquilla/contracts/generated/v4/git_status_metadata.py new file mode 100644 index 0000000000..8c025b998e --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/git_status_metadata.py @@ -0,0 +1,13 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: 6fd00129051d12d6745e883c72d96063da4107b8d0c63e26a193a05ec1a28ffd +# ruff: noqa + +from typing import Final + +GIT_STATUS_CONTRACT_KIND: Final = 'query' +GIT_STATUS_METHOD: Final = 'git.status' +GIT_STATUS_SCOPE: Final = 'operator.read' +GIT_STATUS_IDEMPOTENCY: Final = 'read-only' +GIT_STATUS_TIMEOUT: Final = {'policy': 'transport'} +GIT_STATUS_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'git.status'} +GIT_STATUS_ERRORS: Final = [{'code': 'OWNER_REQUIRED'}, {'code': 'INVALID_PARAMS'}, {'code': 'GIT_UNAVAILABLE'}, {'code': 'INTERNAL_ERROR'}] diff --git a/src/opensquilla/gateway/adapters/git_contract.py b/src/opensquilla/gateway/adapters/git_contract.py new file mode 100644 index 0000000000..addaa1a0a4 --- /dev/null +++ b/src/opensquilla/gateway/adapters/git_contract.py @@ -0,0 +1,57 @@ +"""Generated Contract bindings for read-only Git Gateway methods.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, Final + +from opensquilla.gateway.adapters._generated_contract_bindings import ( + generated_contract_bindings, + register_generated_contract_binding, +) +from opensquilla.gateway.adapters.contract_method import ( + ErrorFactory, + GuestAllowedChecker, + MethodRegistry, +) + +GIT_CONTRACT_METHODS: Final = ( + "git.status", + "git.diff", +) + + +class GitContractError(ValueError): + """A Git success payload violated its generated Contract.""" + + +_BINDINGS: Final = generated_contract_bindings( + GIT_CONTRACT_METHODS, + GitContractError, +) + + +def register_git_contract[ContextT, ResultT]( + registry: MethodRegistry[ContextT], + method: str, + implementation: Callable[[Any, ContextT], Awaitable[ResultT]], + *, + internal_error: ErrorFactory, + guest_allowed_checker: GuestAllowedChecker, +) -> Callable[[Any, ContextT], Awaitable[ResultT]]: + return register_generated_contract_binding( + registry, + _BINDINGS, + method, + implementation, + internal_error=internal_error, + guest_allowed_checker=guest_allowed_checker, + unsupported_contract="git", + ) + + +__all__ = [ + "GIT_CONTRACT_METHODS", + "GitContractError", + "register_git_contract", +] diff --git a/src/opensquilla/gateway/rpc/__init__.py b/src/opensquilla/gateway/rpc/__init__.py index 9d2076e211..34ef00e154 100644 --- a/src/opensquilla/gateway/rpc/__init__.py +++ b/src/opensquilla/gateway/rpc/__init__.py @@ -58,6 +58,7 @@ import opensquilla.gateway.rpc_cron # noqa: E402, F401 import opensquilla.gateway.rpc_diagnostics # noqa: E402, F401 import opensquilla.gateway.rpc_doctor # noqa: E402, F401 +import opensquilla.gateway.rpc_git # noqa: E402, F401 import opensquilla.gateway.rpc_goals # noqa: E402, F401 import opensquilla.gateway.rpc_logs # noqa: E402, F401 import opensquilla.gateway.rpc_memory # noqa: E402, F401 diff --git a/src/opensquilla/gateway/rpc_git.py b/src/opensquilla/gateway/rpc_git.py new file mode 100644 index 0000000000..2037e22ac0 --- /dev/null +++ b/src/opensquilla/gateway/rpc_git.py @@ -0,0 +1,271 @@ +"""Owner-only read-only Git inspection RPC (``git.status`` and ``git.diff``).""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Final + +from opensquilla.gateway.adapters.git_contract import register_git_contract +from opensquilla.gateway.guest_rpc_policy import is_guest_rpc_method_allowed +from opensquilla.gateway.rpc import RpcContext, RpcHandlerError, get_dispatcher +from opensquilla.gateway.session_services import get_session_storage +from opensquilla.git_runtime import GitRunResult, GitRunState, run_git + +_d = get_dispatcher() +_GIT_TIMEOUT_SECONDS: Final = 5.0 +_MAX_DIFF_CHARACTERS: Final = 200_000 +_BINARY_DIFF_MARKERS: Final[tuple[str, ...]] = ("Binary files", "GIT binary patch") +_STATUS_LETTERS: Final[dict[str, str]] = { + "M": "modified", + "A": "added", + "D": "deleted", + "R": "renamed", + "C": "added", + "T": "modified", +} + + +def _require_owner(ctx: RpcContext) -> None: + if not ctx.principal.is_owner: + raise RpcHandlerError( + "OWNER_REQUIRED", + "Git inspection requires a locally proven owner.", + ) + + +def _params(params: dict | None) -> dict[str, Any]: + """Return the raw params mapping; both handlers read it defensively.""" + + return params if isinstance(params, dict) else {} + + +async def _resolve_workdir(params: dict[str, Any], ctx: RpcContext) -> str: + """Resolve the requested project workspace path, or the process directory.""" + + workspace_id = params.get("workspaceId") + if isinstance(workspace_id, str) and workspace_id.strip(): + storage = get_session_storage(ctx.session_manager) + workspace = ( + await storage.get_project_workspace(workspace_id.strip()) + if storage is not None + else None + ) + if workspace is None or workspace.removed_at is not None: + raise RpcHandlerError("WORKSPACE_NOT_FOUND", "Project workspace not found.") + candidate = Path(workspace.path) + else: + candidate = Path.cwd() + resolved = candidate.expanduser() + if not resolved.is_absolute(): + resolved = resolved.absolute() + return str(resolved) + + +async def _git(args: Sequence[str], cwd: str) -> GitRunResult: + """Run one bounded Git command off the event loop.""" + + return await asyncio.to_thread(run_git, args, cwd, _GIT_TIMEOUT_SECONDS) + + +def _unavailable_status(reason: str) -> dict[str, Any]: + """Return a fully populated status payload for an unusable repository.""" + + return { + "available": False, + "workspacePath": None, + "branch": None, + "detached": False, + "unborn": False, + "headOid": None, + "ahead": 0, + "behind": 0, + "clean": True, + "files": [], + "reason": reason, + } + + +def _rev_count(result: GitRunResult) -> int: + if result.state is not GitRunState.OK: + return 0 + try: + return max(int(result.stdout_text.strip()), 0) + except ValueError: + return 0 + + +def _parse_status_listing(text: str) -> list[dict[str, Any]]: + """Parse ``git status --porcelain=v1 -z`` output into Contract file entries.""" + + files: list[dict[str, Any]] = [] + entries = text.split("\0") + index = 0 + while index < len(entries): + entry = entries[index] + index += 1 + if len(entry) < 4 or entry[2] != " ": + continue + code = entry[:2] + path = entry[3:] + if not path: + continue + # In ``-z`` output a rename/copy entry is followed by its source path. + previous_path: str | None = None + if "R" in code or "C" in code: + if index < len(entries) and entries[index]: + previous_path = entries[index] + index += 1 + if code == "??": + files.append({"path": path, "status": "untracked", "staged": False}) + continue + staged = code[0] not in {" ", "?"} + if code in {"AA", "DD"} or "U" in code: + status = "conflicted" + else: + status = _STATUS_LETTERS.get(code[0] if staged else code[1], "modified") + payload: dict[str, Any] = {"path": path, "status": status, "staged": staged} + if previous_path is not None: + payload["previousPath"] = previous_path + files.append(payload) + return files + + +async def _handle_git_status( + params: dict | None, + ctx: RpcContext, +) -> dict[str, Any]: + _require_owner(ctx) + workdir = await _resolve_workdir(_params(params), ctx) + + probe = await _git(("rev-parse", "--is-inside-work-tree"), workdir) + if probe.state is GitRunState.UNAVAILABLE: + return {"status": _unavailable_status("git_unavailable")} + if probe.state is not GitRunState.OK or probe.stdout_text.strip() != "true": + return {"status": _unavailable_status("not_repository")} + + toplevel = await _git(("rev-parse", "--show-toplevel"), workdir) + workspace_path: str | None = None + if toplevel.state is GitRunState.OK: + workspace_path = toplevel.stdout_text.strip() or None + + symbolic = await _git(("symbolic-ref", "--short", "HEAD"), workdir) + branch: str | None = None + if symbolic.state is GitRunState.OK: + branch = symbolic.stdout_text.strip() or None + + head = await _git(("rev-parse", "HEAD"), workdir) + head_oid: str | None = None + if head.state is GitRunState.OK: + head_oid = head.stdout_text.strip() or None + + ahead = _rev_count(await _git(("rev-list", "--count", "@{u}..HEAD"), workdir)) + behind = _rev_count(await _git(("rev-list", "--count", "HEAD..@{u}"), workdir)) + + listing = await _git( + ("status", "--porcelain=v1", "-z", "--untracked-files=all"), + workdir, + ) + files = _parse_status_listing(listing.stdout_text) if listing.state is GitRunState.OK else [] + return { + "status": { + "available": True, + "workspacePath": workspace_path, + "branch": branch, + "detached": branch is None, + "unborn": head_oid is None, + "headOid": head_oid, + "ahead": ahead, + "behind": behind, + "clean": not files, + "files": files, + "reason": None, + } + } + + +def _empty_diff(path: str) -> dict[str, Any]: + """Return the empty diff shape used when Git cannot answer at all.""" + + return { + "path": path, + "diff": "", + "binary": False, + "truncated": False, + "additions": 0, + "deletions": 0, + } + + +def _diff_line_counts(text: str) -> tuple[int, int]: + additions = 0 + deletions = 0 + for line in text.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + additions += 1 + elif line.startswith("-") and not line.startswith("---"): + deletions += 1 + return additions, deletions + + +async def _handle_git_diff( + params: dict | None, + ctx: RpcContext, +) -> dict[str, Any]: + _require_owner(ctx) + values = _params(params) + path = values.get("path") + if not isinstance(path, str) or not path.strip(): + raise RpcHandlerError("INVALID_PARAMS", "path is required") + staged = values.get("staged") is True + workdir = await _resolve_workdir(values, ctx) + + args: list[str] = ["diff", "--no-ext-diff", "--no-textconv", "--no-color"] + if staged: + args.append("--cached") + args.extend(("--", path)) + + result = await _git(tuple(args), workdir) + if result.state in (GitRunState.UNAVAILABLE, GitRunState.NOT_REPOSITORY): + return {"diff": _empty_diff(path)} + + text = result.stdout_text + additions, deletions = _diff_line_counts(text) + binary = any(marker in text for marker in _BINARY_DIFF_MARKERS) + truncated = len(text) > _MAX_DIFF_CHARACTERS + if truncated: + text = text[:_MAX_DIFF_CHARACTERS] + return { + "diff": { + "path": path, + "diff": text, + "binary": binary, + "truncated": truncated, + "additions": additions, + "deletions": deletions, + } + } + + +_GIT_CONTRACT_IMPLEMENTATIONS = { + "git.status": _handle_git_status, + "git.diff": _handle_git_diff, +} + +_GIT_CONTRACT_HANDLERS = { + method: register_git_contract( + _d, + method, + implementation, + internal_error=RpcHandlerError, + guest_allowed_checker=is_guest_rpc_method_allowed, + ) + for method, implementation in _GIT_CONTRACT_IMPLEMENTATIONS.items() +} + + +__all__ = [ + "_handle_git_diff", + "_handle_git_status", +] diff --git a/src/opensquilla/gateway/scopes.py b/src/opensquilla/gateway/scopes.py index 30e46b99a2..2d16fc5c60 100644 --- a/src/opensquilla/gateway/scopes.py +++ b/src/opensquilla/gateway/scopes.py @@ -153,6 +153,8 @@ "sessions.unsubscribe": READ_SCOPE, "workspaces.list": READ_SCOPE, # OpenSquilla-only; owner-guarded local paths. "workspaces.references.read": READ_SCOPE, + "git.status": READ_SCOPE, # OpenSquilla-only; owner-guarded local repository inspection. + "git.diff": READ_SCOPE, "sessions.messages.snapshot": READ_SCOPE, "sessions.messages.snapshot.read": READ_SCOPE, "sessions.messages.resume": READ_SCOPE, diff --git a/tests/test_ci/test_rpc_architecture_contracts.py b/tests/test_ci/test_rpc_architecture_contracts.py index 323fe9e94d..5a4addbc48 100644 --- a/tests/test_ci/test_rpc_architecture_contracts.py +++ b/tests/test_ci/test_rpc_architecture_contracts.py @@ -133,7 +133,8 @@ # Retire router learning status and training feedback (two generated methods). # Retire the three advanced agent administration methods. # Add video-generation model discovery without changing existing method names. -RUNTIME_RPC_METHOD_BASELINE = 285 +# Add the read-only git workbench status and diff readers. +RUNTIME_RPC_METHOD_BASELINE = 287 RUNTIME_RPC_METHOD_DIGEST = "29b0b13c082534442371c559806c483c45d16be045f132a304fc328a6512f9ca" STATIC_RPC_DECORATOR_BASELINE = 63 @@ -1418,9 +1419,11 @@ def test_runtime_rpc_surface_is_exact_and_contract_methods_use_generic_adapter() } sidechat_methods = {"sessions.sidechat.create", "sessions.sidechat.close"} workspace_methods = {"workspaces.create"} + git_methods = {"git.status", "git.diff"} assert terminal_methods.issubset(methods) assert sidechat_methods.issubset(methods) assert workspace_methods.issubset(methods) + assert git_methods.issubset(methods) consume_entry = registry.get_entry("terminal.consume") assert consume_entry is not None assert consume_entry.required_scope == "operator.write" @@ -1429,7 +1432,13 @@ def test_runtime_rpc_surface_is_exact_and_contract_methods_use_generic_adapter() # The terminal, sidechat and workspace creation methods add to the frozen # legacy inventory. Keep the historical digest meaningful while pinning # new methods explicitly above. - legacy_methods = set(methods) - terminal_methods - sidechat_methods - workspace_methods + legacy_methods = ( + set(methods) + - terminal_methods + - sidechat_methods + - workspace_methods + - git_methods + ) digest = hashlib.sha256(("\n".join(sorted(legacy_methods)) + "\n").encode()).hexdigest() assert digest == RUNTIME_RPC_METHOD_DIGEST diff --git a/tests/test_gateway/test_sandbox_runtime_contract_registration.py b/tests/test_gateway/test_sandbox_runtime_contract_registration.py index 360add9a83..757e65b254 100644 --- a/tests/test_gateway/test_sandbox_runtime_contract_registration.py +++ b/tests/test_gateway/test_sandbox_runtime_contract_registration.py @@ -162,7 +162,7 @@ def test_production_registry_uses_contract_wrappers_without_surface_drift() -> N # V2 recovery, video discovery, message search, five interactive terminal # methods, side create/close and workspace creation add sixteen to the # previous 269-method surface. - assert len(registry.list_methods()) == 285 + assert len(registry.list_methods()) == 287 workspace_entry = registry.get_entry("workspaces.create") assert workspace_entry is not None assert workspace_entry.required_scope == "operator.write" diff --git a/tests/test_gateway/test_turn_admission_composition.py b/tests/test_gateway/test_turn_admission_composition.py index 19ae8cd7d1..2f8b4c5d1b 100644 --- a/tests/test_gateway/test_turn_admission_composition.py +++ b/tests/test_gateway/test_turn_admission_composition.py @@ -154,7 +154,7 @@ def test_fresh_rpc_import_preserves_contract_entries_without_an_import_cycle() - # Temporary side conversations add their create and close Contracts. # Workspace creation adds its own write Contract. # Keep the exact count so an unrelated RPC cannot slip into the registry. -assert len(registry.list_methods()) == 285 +assert len(registry.list_methods()) == 287 workspace_entry = registry.get_entry("workspaces.create") assert workspace_entry is not None assert workspace_entry.required_scope == "operator.write"