Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
197
web/src/api/files.ts
Normal file
197
web/src/api/files.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
DirectoryListResult,
|
||||
FileSearchQuery,
|
||||
FileSearchResult,
|
||||
FilesAPI,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
type WebDirectoryEntry = {
|
||||
name?: string;
|
||||
path?: string;
|
||||
isDirectory?: boolean;
|
||||
isFile?: boolean;
|
||||
isSymbolicLink?: boolean;
|
||||
};
|
||||
|
||||
type WebDirectoryListResponse = {
|
||||
directory?: string;
|
||||
path?: string;
|
||||
entries?: WebDirectoryEntry[];
|
||||
};
|
||||
|
||||
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
||||
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
|
||||
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
||||
|
||||
return {
|
||||
directory,
|
||||
entries: entries
|
||||
.filter((entry): entry is Required<Pick<WebDirectoryEntry, 'name' | 'path'>> & { isDirectory?: boolean } =>
|
||||
Boolean(entry && typeof entry.name === 'string' && typeof entry.path === 'string')
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: normalizePath(entry.path),
|
||||
isDirectory: Boolean(entry.isDirectory),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const createWebFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const params = new URLSearchParams();
|
||||
if (target) {
|
||||
params.set('path', target);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
const result = (await response.json()) as WebDirectoryListResponse;
|
||||
return toDirectoryListResult(target, result);
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const directory = normalizePath(payload.directory);
|
||||
if (directory) {
|
||||
params.set('directory', directory);
|
||||
}
|
||||
|
||||
params.set('query', payload.query);
|
||||
params.set('dirs', 'false');
|
||||
params.set('type', 'file');
|
||||
|
||||
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
|
||||
params.set('limit', String(payload.maxResults));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/find/file?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const result = (await response.json()) as string[];
|
||||
const files = Array.isArray(result) ? result : [];
|
||||
|
||||
return files.map((relativePath) => ({
|
||||
path: normalizePath(`${directory}/${relativePath}`),
|
||||
preview: [normalizePath(relativePath)],
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to read file');
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
return { content, path: target };
|
||||
},
|
||||
|
||||
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to write file');
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: Boolean((result as { success?: boolean }).success),
|
||||
path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async delete(path: string): Promise<{ success: boolean }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to delete file');
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
return { success: Boolean((result as { success?: boolean }).success) };
|
||||
},
|
||||
|
||||
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> {
|
||||
const response = await fetch('/api/fs/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oldPath, newPath }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to rename file');
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: Boolean((result as { success?: boolean }).success),
|
||||
path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : newPath,
|
||||
};
|
||||
},
|
||||
|
||||
async revealPath(targetPath: string): Promise<{ success: boolean }> {
|
||||
const response = await fetch('/api/fs/reveal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: normalizePath(targetPath) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to reveal path');
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
return { success: Boolean((result as { success?: boolean }).success) };
|
||||
},
|
||||
});
|
||||
60
web/src/api/git.ts
Normal file
60
web/src/api/git.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as gitApiHttp from '@openchamber/ui/lib/gitApiHttp';
|
||||
import type {
|
||||
GitAPI,
|
||||
CreateGitCommitOptions,
|
||||
GitLogOptions,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: gitApiHttp.checkIsGitRepository,
|
||||
getGitStatus: gitApiHttp.getGitStatus,
|
||||
getGitDiff: gitApiHttp.getGitDiff,
|
||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||
revertGitFile: gitApiHttp.revertGitFile,
|
||||
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
||||
getGitBranches: gitApiHttp.getGitBranches,
|
||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
|
||||
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
||||
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
|
||||
listGitWorktrees: gitApiHttp.listGitWorktrees,
|
||||
validateGitWorktree: gitApiHttp.validateGitWorktree,
|
||||
createGitWorktree: gitApiHttp.createGitWorktree,
|
||||
deleteGitWorktree: gitApiHttp.deleteGitWorktree,
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
|
||||
return gitApiHttp.createGitCommit(directory, message, options);
|
||||
},
|
||||
gitPush: gitApiHttp.gitPush,
|
||||
gitPull: gitApiHttp.gitPull,
|
||||
gitFetch: gitApiHttp.gitFetch,
|
||||
checkoutBranch: gitApiHttp.checkoutBranch,
|
||||
createBranch: gitApiHttp.createBranch,
|
||||
renameBranch: gitApiHttp.renameBranch,
|
||||
getGitLog(directory: string, options?: GitLogOptions) {
|
||||
return gitApiHttp.getGitLog(directory, options);
|
||||
},
|
||||
getCommitFiles: gitApiHttp.getCommitFiles,
|
||||
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
||||
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
|
||||
setGitIdentity: gitApiHttp.setGitIdentity,
|
||||
getGitIdentities: gitApiHttp.getGitIdentities,
|
||||
createGitIdentity: gitApiHttp.createGitIdentity,
|
||||
updateGitIdentity: gitApiHttp.updateGitIdentity,
|
||||
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
|
||||
getRemotes: gitApiHttp.getRemotes,
|
||||
rebase: gitApiHttp.rebase,
|
||||
abortRebase: gitApiHttp.abortRebase,
|
||||
continueRebase: gitApiHttp.continueRebase,
|
||||
merge: gitApiHttp.merge,
|
||||
abortMerge: gitApiHttp.abortMerge,
|
||||
continueMerge: gitApiHttp.continueMerge,
|
||||
stash: gitApiHttp.stash,
|
||||
stashPop: gitApiHttp.stashPop,
|
||||
getConflictDetails: gitApiHttp.getConflictDetails,
|
||||
worktree: {
|
||||
list: gitApiHttp.listGitWorktrees,
|
||||
validate: gitApiHttp.validateGitWorktree,
|
||||
create: gitApiHttp.createGitWorktree,
|
||||
remove: gitApiHttp.deleteGitWorktree,
|
||||
},
|
||||
});
|
||||
233
web/src/api/github.ts
Normal file
233
web/src/api/github.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestsListResult,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestCreateInput,
|
||||
GitHubPullRequestMergeInput,
|
||||
GitHubPullRequestMergeResult,
|
||||
GitHubPullRequestReadyInput,
|
||||
GitHubPullRequestReadyResult,
|
||||
GitHubPullRequestUpdateInput,
|
||||
GitHubPullRequestStatus,
|
||||
GitHubDeviceFlowComplete,
|
||||
GitHubDeviceFlowStart,
|
||||
GitHubUserSummary,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
export const createWebGitHubAPI = (): GitHubAPI => ({
|
||||
async authStatus(): Promise<GitHubAuthStatus> {
|
||||
const response = await fetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authStart(): Promise<GitHubDeviceFlowStart> {
|
||||
const response = await fetch('/api/github/auth/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const payload = await jsonOrNull<GitHubDeviceFlowStart & { error?: string }>(response);
|
||||
if (!response.ok || !payload || !('deviceCode' in payload)) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to start GitHub auth');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
|
||||
const response = await fetch('/api/github/auth/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ deviceCode }),
|
||||
});
|
||||
const payload = await jsonOrNull<GitHubDeviceFlowComplete & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to complete GitHub auth');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authDisconnect(): Promise<{ removed: boolean }> {
|
||||
const response = await fetch('/api/github/auth', { method: 'DELETE', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitHub');
|
||||
}
|
||||
return { removed: Boolean(payload?.removed) };
|
||||
},
|
||||
|
||||
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
|
||||
const response = await fetch('/api/github/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to activate GitHub account');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async me(): Promise<GitHubUserSummary> {
|
||||
const response = await fetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitHub user');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prStatus(directory: string, branch: string, remote?: string): Promise<GitHubPullRequestStatus> {
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
branch,
|
||||
...(remote ? { remote } : {}),
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/github/pr/status?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load PR status');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
|
||||
const response = await fetch('/api/github/pr/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to create PR');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest> {
|
||||
const response = await fetch('/api/github/pr/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to update PR');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
||||
const response = await fetch('/api/github/pr/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequestMergeResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to merge PR');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
|
||||
const response = await fetch('/api/github/pr/ready', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequestReadyResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to mark PR ready');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const response = await fetch(
|
||||
`/api/github/pulls/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GitHubPullRequestsListResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to load pull requests');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prContext(
|
||||
directory: string,
|
||||
number: number,
|
||||
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
|
||||
): Promise<GitHubPullRequestContextResult> {
|
||||
const url = new URL('/api/github/pulls/context', window.location.origin);
|
||||
url.searchParams.set('directory', directory);
|
||||
url.searchParams.set('number', String(number));
|
||||
if (options?.includeDiff) {
|
||||
url.searchParams.set('diff', '1');
|
||||
}
|
||||
if (options?.includeCheckDetails) {
|
||||
url.searchParams.set('checkDetails', '1');
|
||||
}
|
||||
const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubPullRequestContextResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to load pull request context');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const response = await fetch(
|
||||
`/api/github/issues/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load issues');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
||||
const response = await fetch(
|
||||
`/api/github/issues/get?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubIssueGetResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load issue');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
|
||||
const response = await fetch(
|
||||
`/api/github/issues/comments?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubIssueCommentsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
23
web/src/api/index.ts
Normal file
23
web/src/api/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import { createWebTerminalAPI } from './terminal';
|
||||
import { createWebGitAPI } from './git';
|
||||
import { createWebFilesAPI } from './files';
|
||||
import { createWebSettingsAPI } from './settings';
|
||||
import { createWebPermissionsAPI } from './permissions';
|
||||
import { createWebNotificationsAPI } from './notifications';
|
||||
import { createWebToolsAPI } from './tools';
|
||||
import { createWebPushAPI } from './push';
|
||||
import { createWebGitHubAPI } from './github';
|
||||
|
||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
||||
terminal: createWebTerminalAPI(),
|
||||
git: createWebGitAPI(),
|
||||
files: createWebFilesAPI(),
|
||||
settings: createWebSettingsAPI(),
|
||||
permissions: createWebPermissionsAPI(),
|
||||
notifications: createWebNotificationsAPI(),
|
||||
github: createWebGitHubAPI(),
|
||||
push: createWebPushAPI(),
|
||||
tools: createWebToolsAPI(),
|
||||
});
|
||||
77
web/src/api/notifications.ts
Normal file
77
web/src/api/notifications.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean> => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
console.info('Notifications not supported in this environment', payload);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn('Notification permission not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new Notification(payload?.title ?? 'OpenChamber', {
|
||||
body: payload?.body,
|
||||
tag: payload?.tag,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send notification', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyWithTauri = async (payload?: NotificationPayload): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
if (!tauri?.core?.invoke) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauri.core.invoke('desktop_notify', {
|
||||
payload: {
|
||||
title: payload?.title,
|
||||
body: payload?.body,
|
||||
tag: payload?.tag,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to send native notification (tauri)', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebNotificationsAPI = (): NotificationsAPI => ({
|
||||
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
||||
return (await notifyWithTauri(payload)) || notifyWithWebAPI(payload);
|
||||
},
|
||||
canNotify: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
if (tauri?.core?.invoke) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false;
|
||||
},
|
||||
});
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
15
web/src/api/permissions.ts
Normal file
15
web/src/api/permissions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebPermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
|
||||
return { success: true, path: request.path };
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
59
web/src/api/push.ts
Normal file
59
web/src/api/push.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T | null> => {
|
||||
try {
|
||||
const res = await fetch(input, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebPushAPI = (): PushAPI => ({
|
||||
async getVapidPublicKey() {
|
||||
return fetchJson<{ publicKey: string }>('/api/push/vapid-public-key');
|
||||
},
|
||||
|
||||
async subscribe(payload: PushSubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unsubscribe(payload: PushUnsubscribePayload) {
|
||||
return fetchJson<{ ok: true }>('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async setVisibility(payload: { visible: boolean }) {
|
||||
return fetchJson<{ ok: true }>('/api/push/visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
58
web/src/api/settings.ts
Normal file
58
web/src/api/settings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||
const RELOAD_ENDPOINT = '/api/config/reload';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createWebSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load settings: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return {
|
||||
settings: payload,
|
||||
source: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return payload;
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to restart OpenCode');
|
||||
}
|
||||
return { restarted: true };
|
||||
},
|
||||
});
|
||||
74
web/src/api/terminal.ts
Normal file
74
web/src/api/terminal.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
connectTerminalStream,
|
||||
createTerminalSession,
|
||||
resizeTerminal,
|
||||
sendTerminalInput,
|
||||
closeTerminal,
|
||||
restartTerminalSession,
|
||||
forceKillTerminal,
|
||||
} from '@openchamber/ui/lib/terminalApi';
|
||||
import type {
|
||||
TerminalAPI,
|
||||
TerminalHandlers,
|
||||
TerminalStreamOptions,
|
||||
CreateTerminalOptions,
|
||||
ResizeTerminalPayload,
|
||||
TerminalSession,
|
||||
ForceKillOptions,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const getRetryPolicy = (options?: TerminalStreamOptions) => {
|
||||
const retry = options?.retry;
|
||||
return {
|
||||
maxRetries: retry?.maxRetries ?? 3,
|
||||
initialRetryDelay: retry?.initialDelayMs ?? 1000,
|
||||
maxRetryDelay: retry?.maxDelayMs ?? 8000,
|
||||
connectionTimeout: options?.connectionTimeoutMs ?? 10000,
|
||||
};
|
||||
};
|
||||
|
||||
export const createWebTerminalAPI = (): TerminalAPI => ({
|
||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
return createTerminalSession(options);
|
||||
},
|
||||
|
||||
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions) {
|
||||
const unsubscribe = connectTerminalStream(
|
||||
sessionId,
|
||||
handlers.onEvent,
|
||||
handlers.onError,
|
||||
getRetryPolicy(options)
|
||||
);
|
||||
|
||||
return {
|
||||
close: () => unsubscribe(),
|
||||
};
|
||||
},
|
||||
|
||||
async sendInput(sessionId: string, input: string): Promise<void> {
|
||||
await sendTerminalInput(sessionId, input);
|
||||
},
|
||||
|
||||
async resize(payload: ResizeTerminalPayload): Promise<void> {
|
||||
await resizeTerminal(payload.sessionId, payload.cols, payload.rows);
|
||||
},
|
||||
|
||||
async close(sessionId: string): Promise<void> {
|
||||
await closeTerminal(sessionId);
|
||||
},
|
||||
|
||||
async restartSession(
|
||||
currentSessionId: string,
|
||||
options: CreateTerminalOptions
|
||||
): Promise<TerminalSession> {
|
||||
return restartTerminalSession(currentSessionId, {
|
||||
cwd: options.cwd ?? '',
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
});
|
||||
},
|
||||
|
||||
async forceKill(options: ForceKillOptions): Promise<void> {
|
||||
await forceKillTerminal(options);
|
||||
},
|
||||
});
|
||||
22
web/src/api/tools.ts
Normal file
22
web/src/api/tools.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createWebToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
15
web/src/main.tsx
Normal file
15
web/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createWebAPIs } from './api';
|
||||
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import '@openchamber/ui/index.css';
|
||||
import '@openchamber/ui/styles/fonts';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
}
|
||||
}
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
|
||||
|
||||
import('@openchamber/ui/main');
|
||||
Reference in New Issue
Block a user