+
-
+
{img.fileName}
- {formatDate(img.createdTime)}
+ {!isGitHubMode && {formatDate(img.createdTime)}}
-
- {refDocsFor === img.fileName && (
+ {!isGitHubMode && refDocsFor === img.fileName && (
{refDocsLoading ? (
Loading...
@@ -204,7 +320,10 @@ export const ImgManagement: FC = () => {
};
const listTemplate = (items: ImgListItem[]) => {
- if (!items || items.length === 0) return
Empty
;
+ if (!items || items.length === 0) {
+ const message = githubListLoading ? 'Loading...' : githubListError ?? 'Empty';
+ return
{message}
;
+ }
return
{items.map((img, i) => itemTemplate(img, i))}
;
};
@@ -229,6 +348,7 @@ export const ImgManagement: FC = () => {
id="img-management-icon"
toolTipContent="Images"
onClick={() => {
+ if (isGitHubMode) setGithubListLoading(true);
setShowImgManagementModal(true);
}}
/>
diff --git a/client/src/components/Menu/Empty.tsx b/client/src/components/Menu/Empty.tsx
index 61b5b9c..923c22b 100644
--- a/client/src/components/Menu/Empty.tsx
+++ b/client/src/components/Menu/Empty.tsx
@@ -1,12 +1,27 @@
import { Button } from 'primereact/button';
+import { ProgressSpinner } from 'primereact/progressspinner';
import { FC, useState } from 'react';
+import { useSelector } from 'react-redux';
import { FolderSelectorModal } from '@/components/FolderSelector/FolderSelector';
-import { useUpdateSettingsMutation } from '@/redux-api/settings';
+import { useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings';
+import { selectGitHubWorkspaceConfig, selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice';
+import { startGitHubInstall, startGitHubLogin, useGitHubAccessToken } from '@/utils/hooks/githubAuthHooks';
import Toast from '@/utils/Toast';
-export const Empty: FC = () => {
+interface EmptyProps {
+ hasWorkspaceError?: boolean;
+ onCreateFirstDoc: () => void;
+}
+
+export const Empty: FC
= ({ hasWorkspaceError = false, onCreateFirstDoc }) => {
const [showFolderSelector, setShowFolderSelector] = useState(false);
+ const workspaceMode = useSelector(selectWorkspaceMode);
+ const githubWorkspaceConfig = useSelector(selectGitHubWorkspaceConfig);
+ const githubAccessToken = useGitHubAccessToken();
+ const { data: settings, isFetching: isSettingsFetching } = useGetSettingsQuery(undefined, {
+ skip: workspaceMode === 'github',
+ });
const [updateSettings] = useUpdateSettingsMutation();
const handleModalHidden = () => {
@@ -26,17 +41,67 @@ export const Empty: FC = () => {
}
};
+ const renderAction = () => {
+ if (workspaceMode === 'github') {
+ if (!githubAccessToken) {
+ return (
+
+ Sign in with GitHub
+
+ );
+ }
+
+ if (hasWorkspaceError) {
+ return (
+
+ Configure GitHub App access
+
+ );
+ }
+
+ if (!githubWorkspaceConfig.owner || !githubWorkspaceConfig.repo || !githubWorkspaceConfig.branch) {
+ return Choose a GitHub workspace in Settings.
;
+ }
+
+ return (
+
+ Create first document
+
+ );
+ }
+
+ if (isSettingsFetching) {
+ return ;
+ }
+
+ if (!settings?.docRootPath) {
+ return (
+ {
+ setShowFolderSelector(true);
+ }}
+ >
+ Select Workspace
+
+ );
+ }
+
+ if (hasWorkspaceError) {
+ return Oops, something went wrong while loading the workspace.
;
+ }
+
+ return (
+
+ Create first document
+
+ );
+ };
+
return (
- {
- setShowFolderSelector(true);
- }}
- >
- Select Workspace
-
+ {renderAction()}
);
diff --git a/client/src/components/Menu/Menu.scss b/client/src/components/Menu/Menu.scss
index 13e0ff4..080a325 100644
--- a/client/src/components/Menu/Menu.scss
+++ b/client/src/components/Menu/Menu.scss
@@ -120,5 +120,8 @@
flex-direction: column;
font-size: 14px;
text-align: center;
+ .pi {
+ margin-right: 0.5rem;
+ }
}
}
diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx
index b48f10b..0b4f196 100644
--- a/client/src/components/Menu/Menu.tsx
+++ b/client/src/components/Menu/Menu.tsx
@@ -2,7 +2,6 @@ import { ContextMenu } from 'primereact/contextmenu';
import { MenuItem as PrimeMenuItem } from 'primereact/menuitem';
import { ProgressSpinner } from 'primereact/progressspinner';
import { ScrollPanel } from 'primereact/scrollpanel';
-import { Tooltip } from 'primereact/tooltip';
import { FC, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
UncontrolledTreeEnvironment,
@@ -20,17 +19,17 @@ import { Empty } from './Empty';
import { useDropDoc, useNewDocItem, usePasteDoc, useUpdateSubDocItems } from './operations';
import { createRenderItem, renderDragBetweenLine, renderItemArrow } from './renderer';
import { Shortcut } from './Shortcut';
-import { TreeDataCtx, TreeRefCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type';
+import { TreeDataCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type';
-import { useGetDocSubItemsQuery } from '@/redux-api/docs';
-import { useGetSettingsQuery } from '@/redux-api/settings';
import { selectCurDoc } from '@/redux-feature/curDocSlice';
-import { selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice';
import { selectOperationMenu, updateSelectedItems } from '@/redux-feature/operationMenuSlice';
+import { useWorkspaceRootItemsQuery } from '@/utils/hooks/workspaceHooks';
import { normalizePath, scrollToView, waitAndCheck, denormalizePath } from '@/utils/utils';
import './Menu.scss';
+const MENU_TREE_ID = 'treeId';
+
export const Menu: FC = () => {
const tree = useRef(null);
const treeEnvRef = useRef(null);
@@ -38,14 +37,12 @@ export const Menu: FC = () => {
const cm = useRef(null);
const [isEnterMenu, setIsEnterMenu] = useState(false);
- const { data: docRootItems = [], isFetching, isSuccess, isError, error } = useGetDocSubItemsQuery();
+ const [isCreatingFirstDoc, setIsCreatingFirstDoc] = useState(false);
+ const { data: docRootItems = [], dataVersion, isFetching, isSuccess, isError } = useWorkspaceRootItemsQuery();
const updateSubDocItems = useUpdateSubDocItems();
const { contentIdent: contentPath } = useSelector(selectCurDoc);
const { copyCutPaths } = useSelector(selectOperationMenu);
- const serverStatus = useSelector(selectServerStatus);
- const { data: settings } = useGetSettingsQuery();
-
const dispatch = useDispatch();
const renderItem = useMemo(() => createRenderItem(), []);
@@ -63,11 +60,11 @@ export const Menu: FC = () => {
// first level sorted docs
children: docRootItems.map((d) => normalizePath(d.path)),
canRename: false,
- data: { path: [], id: 'root', name: 'root', parentIdx: '' },
+ data: { path: [], id: 'root', name: 'root', parentIdx: '', childrenLoaded: true },
},
};
- // the reset of sub docs will be reqeusted in require(when expanding)
+ // the reset of sub docs will be requested in require(when expanding)
return docRootItems.reduce((treeData, docRootItem) => {
const { path, id, name, isFile } = docRootItem;
const parentIdx = 'root';
@@ -78,13 +75,18 @@ export const Menu: FC = () => {
isFolder: !isFile,
children: [],
canRename: true,
- data: { path, id, name, parentIdx },
+ data: { path, id, name, parentIdx, childrenLoaded: isFile },
};
return treeData;
}, root);
- }, [docRootItems, isFetching]);
+ }, [docRootItems, dataVersion]);
const treeDataProvider = useMemo(() => new StaticTreeDataProvider(renderData), [renderData]);
+ const createFirstDoc = () => {
+ setIsCreatingFirstDoc(true);
+ void createNewDocItem(renderData.root, false, treeDataProvider, renderData);
+ };
+
const selectedItemPathKeys = useMemo(() => {
const selectedDocPath = denormalizePath(contentPath);
return selectedDocPath.reduce((pathKeys, pathItem, idx) => {
@@ -101,6 +103,32 @@ export const Menu: FC = () => {
void treeDataProvider.onDidChangeTreeDataEmitter.emit(['root']);
}, [treeDataProvider]);
+ // refresh the expanded folders(basically the tree) when workspace or providers changed
+ useEffect(() => {
+ /** prevents the loop from starting further updates after the effect has become stale or the component has unmounted. */
+ let cancelled = false;
+ const refreshExpandedFolders = async () => {
+ const expandedItems = treeEnvRef.current?.viewState[MENU_TREE_ID]?.expandedItems ?? [];
+ const shallowestFirst = [...expandedItems].sort(
+ (left, right) => denormalizePath(String(left)).length - denormalizePath(String(right)).length,
+ );
+
+ // Parent folders must load before nested expanded folders can be found in the new provider.
+ for (const itemIndex of shallowestFirst) {
+ if (cancelled) return;
+ const item = renderData[itemIndex];
+ if (!item?.isFolder) continue;
+ await updateSubDocItems(item, renderData, treeDataProvider);
+ }
+ };
+
+ void refreshExpandedFolders();
+ return () => {
+ cancelled = true;
+ };
+ }, [dataVersion, renderData, treeDataProvider, updateSubDocItems]);
+
+ // sync the selection of menu with opening doc
useEffect(() => {
if (contentPath) {
const fn = async () => {
@@ -108,7 +136,7 @@ export const Menu: FC = () => {
// TODO: request sub docs in parallel, may need to wait for the parent doc logics
for (const docIdx of expandKeys) {
const docItem = renderData[docIdx];
- if (!docItem || docItem.children?.length) continue;
+ if (!docItem || docItem.data.childrenLoaded) continue;
await updateSubDocItems(docItem, renderData, treeDataProvider);
}
@@ -120,10 +148,10 @@ export const Menu: FC = () => {
await tree.current?.expandSubsequently(expandKeys);
}
- const selectdItemPath = selectedItemPathKeys[selectedItemPathKeys.length - 1];
- tree.current?.selectItems([selectdItemPath]);
+ const selectedItemPath = selectedItemPathKeys[selectedItemPathKeys.length - 1];
+ tree.current?.selectItems([selectedItemPath]);
- const selectedItem = renderData[selectdItemPath];
+ const selectedItem = renderData[selectedItemPath];
if (!selectedItem) return;
// FIXME: any better way to determine when the children have been rendered?
const hasChildren = await waitAndCheck(() =>
@@ -193,17 +221,21 @@ export const Menu: FC = () => {
}
};
- const onExpandItem = async (item: TreeItem) => {
+ const onExpandItem = async (item?: TreeItem) => {
+ if (!item) return;
+ const currentItem = renderData[item.index];
+ if (!currentItem) return;
+
// already fetched
- if (item.children?.length) return;
+ if (currentItem.data.childrenLoaded) return;
- await updateSubDocItems(item, renderData, treeDataProvider);
+ await updateSubDocItems(currentItem, renderData, treeDataProvider);
};
let content: ReactNode = <>>;
if (isSuccess) {
- if (!settings?.docRootPath) {
- content = ;
+ if (docRootItems.length === 0 && !isCreatingFirstDoc) {
+ content = ;
} else {
content = (
@@ -230,13 +262,15 @@ export const Menu: FC = () => {
}}
canDropAt={(items, target) => {
const targetItem = target.targetType === 'between-items' ? target.parentItem : target.targetItem;
- const isAlreadyInTarget = items.find((item) => renderData[targetItem].children?.includes(item.index));
+ const targetTreeItem = renderData[targetItem];
+ if (!targetTreeItem) return false;
+
+ const isAlreadyInTarget = items.some((item) => targetTreeItem.children?.includes(item.index));
if (isAlreadyInTarget) return false;
- if (renderData[targetItem]?.isFolder) return true;
- return false;
+ return Boolean(targetTreeItem.isFolder);
}}
>
-
+
@@ -245,19 +279,7 @@ export const Menu: FC = () => {
} else if (isFetching) {
content = ;
} else if (isError) {
- if (serverStatus === ServerStatus.RUNNING && settings?.docRootPath) {
- content = (
-
-
-
- Ops, something went wrong
-
-
- );
- } else {
- // should install the server to select workspace
- content = ;
- }
+ content = ;
}
return (
@@ -278,9 +300,7 @@ export const Menu: FC = () => {
data: renderData,
}}
>
-
- {content}
-
+ {content}
diff --git a/client/src/components/Menu/MenuItem.tsx b/client/src/components/Menu/MenuItem.tsx
index 0c3dc4f..7f3c5e5 100644
--- a/client/src/components/Menu/MenuItem.tsx
+++ b/client/src/components/Menu/MenuItem.tsx
@@ -77,7 +77,13 @@ export const MenuItem: FC
= ({ title, arrow, context, item, depth
const onClickCommand = async (command: Command) => {
if (command === 'newFile' || command === 'newFolder') {
- await createNewDocItem(item, command === 'newFolder');
+ await createNewDocItem(
+ item,
+ command === 'newFolder',
+ undefined,
+ undefined,
+ context.isExpanded ? undefined : context.expandItem,
+ );
} else if (command === 'delete') {
await deleteDoc(item);
} else if (command === 'rename') {
diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx
index 05487c9..5248f73 100644
--- a/client/src/components/Menu/operations.tsx
+++ b/client/src/components/Menu/operations.tsx
@@ -1,24 +1,24 @@
/* eslint-disable @typescript-eslint/no-magic-numbers */
import { QueryStatus } from '@reduxjs/toolkit/query';
import { InputText } from 'primereact/inputtext';
-import { FC, useContext, useEffect, useRef, useState, ReactNode } from 'react';
+import { FC, useCallback, useContext, useEffect, useRef, useState, ReactNode } from 'react';
import { DraggingPosition, StaticTreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
-import { TreeDataCtx, TreeItemData, TreeRefCtx } from './type';
+import { TreeDataCtx, TreeItemData } from './type';
-import {
- useCreateDocMutation,
- useDeleteDocMutation,
- useModifyDocNameMutation,
- useCopyCutDocMutation,
- useLazyGetDocSubItemsQuery,
-} from '@/redux-api/docs';
import { selectCurDocDirty, updateCurDoc } from '@/redux-feature/curDocSlice';
import { selectOperationMenu, selectSelectedItemIds, updateCopyCut } from '@/redux-feature/operationMenuSlice';
import { useCurPath } from '@/utils/hooks/docHooks';
-import { useDeleteTab, useRenameTab } from '@/utils/hooks/reduxHooks';
+import { useDeleteTab, useRenameTab, useRenameTabs } from '@/utils/hooks/reduxHooks';
+import {
+ useCopyCutWorkspaceDocs,
+ useCreateWorkspaceDoc,
+ useDeleteWorkspaceDocs,
+ useLazyWorkspaceSubItemsQuery,
+ useRenameWorkspaceDoc,
+} from '@/utils/hooks/workspaceHooks';
import Toast from '@/utils/Toast';
import { confirm, denormalizePath, isPathsRelated, normalizePath } from '@/utils/utils';
@@ -31,10 +31,13 @@ export function deleteSubDocItem(
parentItem.children = parentItem.children.filter((childIdx) => childIdx !== idx);
- const isFolder = treeData[idx].isFolder;
+ const item = treeData[idx];
+ if (!item) return true;
+
+ const isFolder = item.isFolder;
if (isFolder) {
- treeData[idx].children?.forEach((childIdx) => {
- deleteSubDocItem(treeData[idx], childIdx, treeData);
+ item.children?.forEach((childIdx) => {
+ deleteSubDocItem(item, childIdx, treeData);
});
}
@@ -47,39 +50,49 @@ export function deleteSubDocItem(
}
export const useUpdateSubDocItems = () => {
- const [getDocSubItems] = useLazyGetDocSubItemsQuery();
-
- return async (
- parentItem: TreeItem,
- treeData: Record>,
- provider: StaticTreeDataProvider,
- ) => {
- const { data: newSubItems, status } = await getDocSubItems({ folderDocPath: parentItem.data.path.join('/') });
- if (status !== QueryStatus.fulfilled) {
- Toast.error('Failed to get sub doc items');
- return;
- }
+ const [getDocSubItems] = useLazyWorkspaceSubItemsQuery();
+
+ return useCallback(
+ async (
+ parentItem: TreeItem,
+ treeData: Record>,
+ provider: StaticTreeDataProvider,
+ ) => {
+ const currentParentItem = treeData[parentItem.index];
+ if (!currentParentItem) return;
+
+ const { data: newSubItems, status } = await getDocSubItems({
+ folderDocPath: currentParentItem.data.path.join('/'),
+ });
+ if (status !== QueryStatus.fulfilled) {
+ Toast.error('Failed to get sub doc items');
+ return false;
+ }
- newSubItems.forEach(({ id, name, isFile, path }) => {
- const idx = normalizePath(path);
- if (treeData[idx]) return;
- treeData[idx] = {
- index: idx,
- canMove: true,
- isFolder: !isFile,
- children: [],
- canRename: true,
- data: { path, id, name, parentIdx: parentItem.index },
- };
- });
- parentItem.children = newSubItems.map((d) => normalizePath(d.path));
- await provider.onDidChangeTreeDataEmitter.emit([parentItem.index]);
- };
+ newSubItems.forEach(({ id, name, isFile, path }) => {
+ const idx = normalizePath(path);
+ if (treeData[idx]) return;
+ treeData[idx] = {
+ index: idx,
+ canMove: true,
+ isFolder: !isFile,
+ children: [],
+ canRename: true,
+ data: { path, id, name, parentIdx: currentParentItem.index, childrenLoaded: isFile },
+ };
+ });
+ currentParentItem.children = newSubItems.map((d) => normalizePath(d.path));
+ currentParentItem.data.childrenLoaded = true;
+ await provider.onDidChangeTreeDataEmitter.emit([currentParentItem.index, ...currentParentItem.children]);
+ return true;
+ },
+ [getDocSubItems],
+ );
};
export const useNewDocItem = () => {
const treeDataCtx = useContext(TreeDataCtx);
- const treeRefCtx = useContext(TreeRefCtx);
+ const updateSubDocItems = useUpdateSubDocItems();
return async (
item: TreeItem,
@@ -87,11 +100,19 @@ export const useNewDocItem = () => {
// for root menu to provide
treeProvider?: StaticTreeDataProvider,
renderData?: Record>,
+ expandParent?: () => void,
) => {
const treeData = renderData ?? treeDataCtx?.data;
const provider = treeProvider ?? treeDataCtx?.provider;
if (!treeData || !provider) return;
+ const currentParent = treeData[item.index];
+ if (!currentParent) return;
+ if (!currentParent.data.childrenLoaded) {
+ const loaded = await updateSubDocItems(currentParent, treeData, provider);
+ if (!loaded) return;
+ }
+
const id = `${Math.random()}`;
const newItem: TreeItem = {
index: id,
@@ -104,16 +125,21 @@ export const useNewDocItem = () => {
path: [],
id,
name: 'newDoc',
+ childrenLoaded: true,
newFile: !isFolder,
newFolder: isFolder,
},
};
treeData[id] = newItem;
- item.children?.unshift(id);
+ currentParent.children?.unshift(id);
- await provider.onDidChangeTreeDataEmitter.emit([item.index]);
+ await provider.onDidChangeTreeDataEmitter.emit([currentParent.index, id]);
- treeRefCtx?.expandItem(item.index);
+ // Expand only after the provider knows about the new child. The render-context action
+ // captures the parent item directly, so it avoids TreeRef's potentially stale item lookup.
+ if (expandParent) {
+ requestAnimationFrame(expandParent);
+ }
};
};
@@ -167,6 +193,7 @@ export const useDeleteEffect = () => {
contentIdent: '',
scrollTop: 0,
headings: [],
+ syncTab: false,
type: 'workspace',
}),
);
@@ -181,7 +208,7 @@ export const useDeleteEffect = () => {
};
export const useDeleteDoc = () => {
- const [deleteDocMutation] = useDeleteDocMutation();
+ const deleteDocMutation = useDeleteWorkspaceDocs();
const treeDataCtx = useContext(TreeDataCtx);
const selectedItemIds = useSelector(selectSelectedItemIds);
@@ -217,7 +244,7 @@ export const useDeleteDoc = () => {
return { filePath: deletedPath, isFile: !isFolder };
});
- await deleteDocMutation(deletePayload).unwrap();
+ await deleteDocMutation(deletePayload);
deleteEffect(deletePayload, true);
await Promise.all(
@@ -265,19 +292,19 @@ export const usePasteDoc = () => {
const dispatch = useDispatch();
const { isCopy: globalIsCopy, copyCutPaths } = useSelector(selectOperationMenu);
- const [getDocSubItems] = useLazyGetDocSubItemsQuery();
+ const [getDocSubItems] = useLazyWorkspaceSubItemsQuery();
- const [copyCutDoc] = useCopyCutDocMutation();
+ const copyCutDoc = useCopyCutWorkspaceDocs();
+ const renameTabs = useRenameTabs();
const updateSubDocItems = useUpdateSubDocItems();
- const { navigate, curPath } = useCurPath();
-
return async ({
pasteParentPathArr,
providedTreeDataCtx,
providedIsCopy,
providedCopyCutPaths,
+ providedCopyCutItems,
onCancel,
}: {
/** the path of the clicked item */
@@ -289,6 +316,8 @@ export const usePasteDoc = () => {
providedIsCopy?: boolean;
/** normalized */
providedCopyCutPaths?: string[];
+ /** Stable drag snapshots; rendered tree data can be replaced while a drag is in progress. */
+ providedCopyCutItems?: TreeItem[];
onCancel?: () => Promise | void;
}) => {
const treeCtx = providedTreeDataCtx ?? treeDataCtx;
@@ -306,10 +335,20 @@ export const usePasteDoc = () => {
return;
}
+ const providedItemsByPath = new Map(
+ providedCopyCutItems?.map((item) => [normalizePath(item.data.path), item] as const) ?? [],
+ );
+ const sourceNames = new Map();
const copyCutPayload = (providedCopyCutPaths ?? copyCutPaths)
- .map((copyCutPath) => {
- // file or dir
- const copyCutDocName = treeData[copyCutPath].data.name;
+ .flatMap((copyCutPath) => {
+ // Prefer the drag snapshot. The provider may already have optimistically detached the source node.
+ const copyCutItem = providedItemsByPath.get(copyCutPath) ?? treeData[copyCutPath];
+ if (!copyCutItem) {
+ Toast.warn(`${denormalizePath(copyCutPath).join('/')} is no longer available`);
+ return [];
+ }
+ const copyCutDocName = copyCutItem.data.name;
+ sourceNames.set(copyCutPath, copyCutDocName);
const pasteParentPath = normalizePath(pasteParentPathArr);
const pasteDoc = treeData[pasteParentPath];
@@ -319,16 +358,11 @@ export const usePasteDoc = () => {
: // paster to root
copyCutDocName;
- return {
- copyCutPath,
- pastePath,
- isCopy,
- isFile: !treeData[copyCutPath].isFolder,
- };
+ return [{ copyCutPath, pastePath, isCopy, isFile: !copyCutItem.isFolder }];
})
.filter(({ pastePath, copyCutPath }) => {
// check if there is a repeat name
- const hasDuplicatedName = pasteParentSubDocs.some((d) => d.name === treeData[copyCutPath].data.name);
+ const hasDuplicatedName = pasteParentSubDocs.some((d) => d.name === sourceNames.get(copyCutPath));
if (hasDuplicatedName) {
Toast.warn(`${denormalizePath(pastePath).join('/') as string} already exist in this folder!`);
@@ -359,23 +393,18 @@ export const usePasteDoc = () => {
return;
}
- await copyCutDoc(copyCutPayload).unwrap();
-
- // if it is cut and current path is included in it, redirect
- const curDocPayload = copyCutPayload.find(({ copyCutPath, isFile }) =>
- isPathsRelated(curPath, denormalizePath(copyCutPath), isFile),
- );
- if (!isCopy && curDocPayload) {
- // if it is a file, direct to the paste path
- if (curDocPayload.isFile) {
- void navigate(`/article/${curDocPayload.pastePath as string}`);
- } else {
- const curFile = curPath.slice(
- curPath.length - (curPath.length - denormalizePath(curDocPayload.copyCutPath).length),
- );
- void navigate(`/article/${normalizePath([curDocPayload.pastePath, ...curFile]) as string}`);
- }
- }
+ await copyCutDoc(copyCutPayload, () => {
+ if (isCopy) return;
+ // Run in the same synchronous turn as the GitHub overlay dispatch. React then observes the destination path
+ // and renamed tabs together, instead of briefly querying the tombstoned source document.
+ renameTabs(
+ copyCutPayload.map(({ copyCutPath, pastePath, isFile }) => ({
+ oldPath: copyCutPath,
+ newPath: pastePath,
+ isFile,
+ })),
+ );
+ });
copyCutPayload.forEach(({ copyCutPath, isCopy: isCopyInPayload }) => {
if (!isCopyInPayload) {
@@ -389,7 +418,7 @@ export const usePasteDoc = () => {
}
});
const pasteParentItem = treeData[pasteParentPathArr.length ? normalizePath(pasteParentPathArr) : 'root'];
- void updateSubDocItems(pasteParentItem, treeData, provider);
+ if (pasteParentItem) void updateSubDocItems(pasteParentItem, treeData, provider);
Toast('updated successfully!');
return true;
@@ -408,7 +437,7 @@ export const usePasteDoc = () => {
};
export const useDropDoc = () => {
- const [getDocSubItems] = useLazyGetDocSubItemsQuery();
+ const [getDocSubItems] = useLazyWorkspaceSubItemsQuery();
const pasteDoc = usePasteDoc();
@@ -425,6 +454,10 @@ export const useDropDoc = () => {
}) => {
const targetItemIdx = target.targetType === 'between-items' ? target.parentItem : target.targetItem;
const targetItem = treeData[targetItemIdx];
+ if (!targetItem) {
+ Toast.warn('Drop target is no longer available');
+ return;
+ }
await pasteDoc({
pasteParentPathArr: targetItem.data.path,
@@ -434,6 +467,7 @@ export const useDropDoc = () => {
},
providedIsCopy: false,
providedCopyCutPaths: items.map((item) => normalizePath(item.data.path)),
+ providedCopyCutItems: items,
onCancel: async () => {
// reorder the moved items
await Promise.all(
@@ -472,7 +506,7 @@ export const CreateNewDocItem: FC = ({ item, arrow, style })
const treeDataCtx = useContext(TreeDataCtx);
- const [createDoc] = useCreateDocMutation();
+ const createDoc = useCreateWorkspaceDoc();
const updateSubDocItems = useUpdateSubDocItems();
const navigate = useNavigate();
@@ -500,7 +534,7 @@ export const CreateNewDocItem: FC = ({ item, arrow, style })
}
try {
- await createDoc({ filePath: norPath, isFile: !isFolder }).unwrap();
+ await createDoc({ filePath: norPath, isFile: !isFolder });
// direct to this new doc if it is a file
if (!isFolder) void navigate(`/article/${norPath as string}`);
@@ -545,7 +579,7 @@ export const RenameDocItem: FC = ({ item, arrow, style }) => {
const { isFolder } = item;
const [newFileName, setNewFileName] = useState(item.data.name);
- const [modifyName] = useModifyDocNameMutation();
+ const modifyName = useRenameWorkspaceDoc();
const updateSubDocItems = useUpdateSubDocItems();
const renameTab = useRenameTab();
@@ -598,16 +632,17 @@ export const RenameDocItem: FC = ({ item, arrow, style }) => {
}
try {
- await modifyName({
- filePath: modifyPath,
- name: newFileName,
- isFile: !isFolder,
- }).unwrap();
+ await modifyName(
+ {
+ filePath: modifyPath,
+ name: newFileName,
+ isFile: !isFolder,
+ },
+ () => renameTab(normalizePath(path), newPath, !isFolder),
+ );
void updateSubDocItems(parentItem, treeData, provider);
- renameTab(normalizePath(path), newPath, !isFolder);
-
Toast('rename successfully!');
} catch (err) {
Toast.error((err as Error).message);
diff --git a/client/src/components/Menu/type.ts b/client/src/components/Menu/type.ts
index 71e4ffb..09d9297 100644
--- a/client/src/components/Menu/type.ts
+++ b/client/src/components/Menu/type.ts
@@ -1,9 +1,10 @@
import { createContext } from 'react';
-import { StaticTreeDataProvider, TreeEnvironmentRef, TreeItem, TreeItemIndex, TreeRef } from 'react-complex-tree';
+import { StaticTreeDataProvider, TreeEnvironmentRef, TreeItem, TreeItemIndex } from 'react-complex-tree';
import { DocTreeNode } from '@/redux-api/docsApiType';
export interface MetaData {
+ childrenLoaded?: boolean;
newFile?: boolean;
newFolder?: boolean;
rename?: boolean;
@@ -16,7 +17,6 @@ export const TreeDataCtx = createContext<{
data: Record>;
} | null>(null);
-export const TreeRefCtx = createContext(null);
export const TreeEnvRefCtx = createContext(null);
export const MenuCtx = createContext<{
isEnterMenu: boolean;
diff --git a/client/src/components/OpenTab/OpenTab.tsx b/client/src/components/OpenTab/OpenTab.tsx
index 67e966d..7c6a9d9 100644
--- a/client/src/components/OpenTab/OpenTab.tsx
+++ b/client/src/components/OpenTab/OpenTab.tsx
@@ -10,6 +10,7 @@ import type { RootState } from '@/store';
import { Icon } from '@/components/Icon/Icon';
import { useGetSettingsQuery } from '@/redux-api/settings';
import { selectCurTabs, Tab } from '@/redux-feature/curDocSlice';
+import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice';
import { selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice';
import { useDeleteTab } from '@/utils/hooks/reduxHooks';
import Toast from '@/utils/Toast';
@@ -46,9 +47,12 @@ function getDisambiguations(tabs: Tab[]): Map {
// eslint-disable-next-line @typescript-eslint/naming-convention
export default function OpenTab() {
const curTabs = useSelector(selectCurTabs);
- const { data: settings } = useGetSettingsQuery();
+ const githubWorkspace = useSelector(selectGithubWorkspace);
+ const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' });
const draftKeys = useSelector((state: RootState) => Object.keys(state.drafts));
const serverStatus = useSelector(selectServerStatus);
+ const workspaceKey =
+ githubWorkspace.mode === 'github' ? getGitHubWorkspaceKey(githubWorkspace.config) : settings?.docRootPath;
const cm = useRef(null);
@@ -62,7 +66,7 @@ export default function OpenTab() {
const activeIdent = curTabs.find((t) => t.active)?.ident;
const closeSavedTabs = () => {
- const savedTabs = curTabs.filter((t) => !draftKeys.includes(getDraftKey(settings?.docRootPath, t.ident)));
+ const savedTabs = curTabs.filter((t) => !draftKeys.includes(getDraftKey(workspaceKey, t.ident)));
void deleteTab(savedTabs.map((t) => t.ident));
};
@@ -96,7 +100,7 @@ export default function OpenTab() {
/>
{curTabs.map(({ ident, active, type, title }) => {
- const draftKey = getDraftKey(settings?.docRootPath, ident);
+ const draftKey = getDraftKey(workspaceKey, ident);
const isDirty = draftKeys.includes(draftKey);
const TabTitle = type === 'workspace' ? denormalizePath(ident).join('/') : title ?? ident;
@@ -104,7 +108,8 @@ export default function OpenTab() {
const tabName = type === 'workspace' ? denormalizePath(ident).slice(-1)[0] : title ?? ident;
const subtitle = disambiguations.get(ident) ?? '';
- const notFoundTab = serverStatus === ServerStatus.CANNOT_CONNECT && type === 'workspace';
+ const notFoundTab =
+ githubWorkspace.mode === 'local' && serverStatus === ServerStatus.CANNOT_CONNECT && type === 'workspace';
const toDoc = () => {
if (notFoundTab) {
diff --git a/client/src/components/Settings/GitHubSettings.tsx b/client/src/components/Settings/GitHubSettings.tsx
new file mode 100644
index 0000000..df127a7
--- /dev/null
+++ b/client/src/components/Settings/GitHubSettings.tsx
@@ -0,0 +1,351 @@
+import { Button } from 'primereact/button';
+import { Checkbox } from 'primereact/checkbox';
+import { Chips } from 'primereact/chips';
+import { Dropdown } from 'primereact/dropdown';
+import { InputText } from 'primereact/inputtext';
+import { FC, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+
+import { Icon } from '@/components/Icon/Icon';
+import { WORKSPACE_SETTINGS_PATH } from '@/constants';
+import {
+ useCreateGitHubRepositoryMutation,
+ useGetGitHubWorkspaceSettingsQuery,
+ useInitializeGitHubWorkspaceMutation,
+ useListGitHubBranchesQuery,
+ useListGitHubRepositoriesQuery,
+} from '@/redux-api/github';
+import { updateTabs } from '@/redux-feature/curDocSlice';
+import { emptyConfig, selectGithubWorkspace, setGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice';
+import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks';
+import { useSaveDoc } from '@/utils/hooks/reduxHooks';
+import Toast from '@/utils/Toast';
+import { confirm } from '@/utils/utils';
+
+export const GitHubSettings: FC = () => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const saveDoc = useSaveDoc();
+ const workspace = useSelector(selectGithubWorkspace);
+ const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, startGitHubInstall, startGitHubLogin } = useGitHubLogin();
+ const {
+ data: repositories = [],
+ error: repositoriesError,
+ isFetching: repositoriesLoading,
+ } = useListGitHubRepositoriesQuery(undefined, { skip: !githubLogin });
+ const savedRepository =
+ workspace.config.owner && workspace.config.repo ? `${workspace.config.owner}/${workspace.config.repo}` : '';
+ const [selectedRepository, setSelectedRepository] = useState(savedRepository);
+ const repository = repositories.find((item) => item.fullName === selectedRepository);
+ const { data: branches = [], isFetching: branchesLoading } = useListGitHubBranchesQuery(
+ repository ? { owner: repository.owner, repo: repository.name } : { owner: '', repo: '' },
+ { skip: !repository },
+ );
+ const [branch, setBranch] = useState(workspace.config.branch);
+ const [docsRoot, setDocsRoot] = useState(workspace.config.docsRoot);
+ const [ignoreDirs, setIgnoreDirs] = useState(workspace.config.ignoreDirs);
+ const {
+ currentData: repositorySettings,
+ error: repositorySettingsError,
+ isFetching: repositorySettingsLoading,
+ isSuccess: repositorySettingsLoaded,
+ refetch: refetchRepositorySettings,
+ } = useGetGitHubWorkspaceSettingsQuery(
+ repository && branch
+ ? { owner: repository.owner, repo: repository.name, branch }
+ : { owner: '', repo: '', branch: '' },
+ { skip: !repository || !branch },
+ );
+ const [newRepoName, setNewRepoName] = useState('');
+ const [newRepoDescription, setNewRepoDescription] = useState('');
+ const [newRepoPrivate, setNewRepoPrivate] = useState(false);
+ const [createRepository, { isLoading: creating }] = useCreateGitHubRepositoryMutation();
+ const [initializeWorkspace, { isLoading: initializing }] = useInitializeGitHubWorkspaceMutation();
+
+ useEffect(() => {
+ setSelectedRepository(savedRepository);
+ setBranch(workspace.config.branch);
+ setDocsRoot(workspace.config.docsRoot);
+ setIgnoreDirs(workspace.config.ignoreDirs);
+ }, [savedRepository, workspace.config.branch, workspace.config.docsRoot, workspace.config.ignoreDirs]);
+
+ useEffect(() => {
+ if (selectedRepository === savedRepository && repository && !branchesLoading && !branches.includes(branch)) {
+ setBranch(repository.defaultBranch);
+ }
+ }, [branch, branches, branchesLoading, repository, savedRepository, selectedRepository]);
+
+ useEffect(() => {
+ if (repositorySettings) {
+ setDocsRoot(repositorySettings.docsRoot);
+ setIgnoreDirs(repositorySettings.ignoreDirs);
+ }
+ }, [repositorySettings]);
+
+ const repositoryOptions = repositories.map((item) => ({ label: item.fullName, value: item.fullName }));
+ if (selectedRepository && !repositoryOptions.some((option) => option.value === selectedRepository)) {
+ repositoryOptions.unshift({ label: selectedRepository, value: selectedRepository });
+ }
+ const repositoryItemTemplate = (option: { label: string; value: string }) => {
+ const repositoryUrl = `https://github.com/${option.value}`;
+
+ return (
+
+ {option.label}
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ window.open(repositoryUrl, '_blank', 'noopener,noreferrer');
+ }}
+ />
+
+ );
+ };
+ const branchOptions = branches.map((item) => ({ label: item, value: item }));
+ if (branch && !branchOptions.some((option) => option.value === branch)) {
+ branchOptions.unshift({ label: branch, value: branch });
+ }
+ const normalizedDocsRoot = docsRoot.replace(/^\/+|\/+$/g, '');
+ const workspaceSettingsChanged = Boolean(
+ repositorySettings &&
+ (repositorySettings.docsRoot !== normalizedDocsRoot ||
+ repositorySettings.ignoreDirs.length !== ignoreDirs.length ||
+ repositorySettings.ignoreDirs.some((directory, index) => directory !== ignoreDirs[index])),
+ );
+
+ const getSelectedConfig = () => ({
+ ...emptyConfig,
+ owner: repository?.owner ?? '',
+ repo: repository?.name ?? '',
+ branch,
+ docsRoot: normalizedDocsRoot,
+ ignoreDirs,
+ });
+
+ if (isGitHubLoginLoading) {
+ return (
+
+
+
+ Loading GitHub account…
+
+
+ );
+ }
+
+ if (!githubLogin) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const selectWorkspace = async () => {
+ if (!repository || !branch || !repositorySettingsLoaded) return;
+ try {
+ const config = getSelectedConfig();
+ if (!repositorySettings) {
+ await initializeWorkspace(config).unwrap();
+ }
+ await saveDoc();
+ dispatch(updateTabs([]));
+ void navigate('/purePage');
+ dispatch(setGitHubWorkspaceConfig(config));
+ Toast('GitHub workspace ready');
+ } catch (error) {
+ Toast.error((error as { message?: string }).message ?? 'Failed to select GitHub workspace');
+ }
+ };
+
+ const updateWorkspaceSettings = async () => {
+ if (!repository || !branch || !repositorySettings || !workspaceSettingsChanged) return;
+ const confirmed = await confirm({
+ header: 'Update GitHub workspace settings?',
+ message: `This will create a commit that updates ${WORKSPACE_SETTINGS_PATH} in the selected branch.`,
+ acceptLabel: 'Update settings',
+ });
+ if (!confirmed) return;
+ try {
+ await initializeWorkspace(getSelectedConfig()).unwrap();
+ await refetchRepositorySettings();
+ Toast('GitHub workspace settings updated');
+ } catch (error) {
+ Toast.error((error as { message?: string }).message ?? 'Failed to update GitHub workspace settings');
+ }
+ };
+
+ const createNewRepository = async () => {
+ if (!newRepoName.trim()) return;
+ try {
+ const result = await createRepository({
+ name: newRepoName.trim(),
+ description: newRepoDescription.trim() || undefined,
+ private: newRepoPrivate,
+ docsRoot: 'docs',
+ }).unwrap();
+ await saveDoc();
+ dispatch(updateTabs([]));
+ void navigate('/purePage');
+ dispatch(setGitHubWorkspaceConfig(result.config));
+ setSelectedRepository(`${result.config.owner}/${result.config.repo}`);
+ Toast('GitHub repository created');
+ } catch (error) {
+ Toast.error((error as { message?: string }).message ?? 'Failed to create GitHub repository');
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {githubAvatarUrl && (
+

+ )}
+
Signed in as {githubLogin}
+
+ {repositoriesError && 'message' in repositoriesError && (
+
{repositoriesError.message}
+ )}
+
+ Repositories appear after this GitHub App is installed on them with read and write Contents permission.
+
+
+
+
+
+
{
+ setSelectedRepository(event.value as string);
+ const selected = repositories.find((item) => item.fullName === event.value);
+ setBranch(selected?.defaultBranch ?? '');
+ setDocsRoot(emptyConfig.docsRoot);
+ setIgnoreDirs([...emptyConfig.ignoreDirs]);
+ }}
+ />
+ {
+ setBranch(event.value as string);
+ setDocsRoot(emptyConfig.docsRoot);
+ setIgnoreDirs([...emptyConfig.ignoreDirs]);
+ }}
+ />
+ {
+ setDocsRoot(event.target.value);
+ }}
+ />
+
+ {
+ setIgnoreDirs(event.value ?? []);
+ }}
+ />
+ {repositorySettingsLoading && Checking workspace settings…}
+ {repositorySettingsLoaded && repositorySettings === null && (
+
+ Workspace settings will be created automatically when you use this workspace.
+
+ )}
+ {repositorySettingsError && 'message' in repositorySettingsError && (
+ {repositorySettingsError.message}
+ )}
+ {workspaceSettingsChanged && (
+
+ Confirm the workspace settings update before using this workspace.
+
+ )}
+
+ {repositorySettings && (
+ void updateWorkspaceSettings()}
+ />
+ )}
+ void selectWorkspace()}
+ />
+
+
+
+
+ {
+ setNewRepoName(event.target.value);
+ }}
+ />
+ {
+ setNewRepoDescription(event.target.value);
+ }}
+ />
+
+ void createNewRepository()}
+ />
+
+ >
+ );
+};
diff --git a/client/src/components/Settings/Settings.scss b/client/src/components/Settings/Settings.scss
index 74b9219..3359fad 100644
--- a/client/src/components/Settings/Settings.scss
+++ b/client/src/components/Settings/Settings.scss
@@ -2,11 +2,40 @@
.settings-container {
width: 600px;
- height: 500px;
+ height: fit-content;
.setting-item {
padding-top: 20px;
margin-bottom: 20px;
border-top: 1px solid $shadowColor;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+ .p-dropdown,
+ .p-inputtext {
+ width: 100%;
+ }
+ .workspace-setting {
+ display: flex;
+ gap: 10px;
+ }
+ .github-access-message {
+ color: var(--shallowTextColor, currentColor);
+ line-height: 1.4;
+ }
+ .github-access-error {
+ color: var(--red-500, #dc2626);
+ }
+ .github-account-details {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ }
+ .github-settings-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
}
.setting-label {
display: block;
@@ -30,7 +59,8 @@
}
.git-service-status {
margin-top: 10px;
- .no-git-service, .git-service-status-active {
+ .no-git-service,
+ .git-service-status-active {
@include flex-center();
i {
margin-right: 5px;
@@ -56,3 +86,31 @@
}
}
}
+
+.github-repository-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ width: 100%;
+ > span:first-child {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.25rem;
+ }
+ .github-repository-open-icon {
+ flex-shrink: 0;
+ width: 1.25rem;
+ height: 1.25rem;
+ opacity: 0.7;
+ transition:
+ background-color 0.15s ease,
+ opacity 0.15s ease;
+ &:hover,
+ &:focus-visible {
+ opacity: 1;
+ }
+ }
+}
diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx
index 966d458..b276b6f 100644
--- a/client/src/components/Settings/Settings.tsx
+++ b/client/src/components/Settings/Settings.tsx
@@ -1,11 +1,20 @@
import { Button } from 'primereact/button';
import { Chips } from 'primereact/chips';
import { InputText } from 'primereact/inputtext';
+import { SelectButton } from 'primereact/selectbutton';
import { FC, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+
+import { GitHubSettings } from './GitHubSettings';
import { FolderSelectorModal } from '@/components/FolderSelector/FolderSelector';
import { useGetGitStatusQuery } from '@/redux-api/git';
import { Settings } from '@/redux-api/settings';
+import { selectCurDocDirty, selectCurDocType, updateTabs } from '@/redux-feature/curDocSlice';
+import { selectGithubWorkspace, setWorkspaceMode, type WorkspaceMode } from '@/redux-feature/githubWorkspaceSlice';
+import { useSaveDoc } from '@/utils/hooks/reduxHooks';
+import { confirm } from '@/utils/utils';
import './Settings.scss';
@@ -14,13 +23,24 @@ export interface SettingsBoxProps {
onUpdateSettings?: (settings: Settings) => void;
}
+const workspaceModeOptions = [
+ { label: 'Local', value: 'local' },
+ { label: 'GitHub', value: 'github' },
+];
+
export const SettingsBox: FC
= ({ settings, onUpdateSettings }) => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const saveDoc = useSaveDoc();
+ const isDirty = useSelector(selectCurDocDirty);
+ const currentDocType = useSelector(selectCurDocType);
+ const workspaceMode = useSelector(selectGithubWorkspace).mode;
const [workspace, setWorkspace] = useState('');
const [ignoreDirs, setIgnoreDirs] = useState([]);
const [showFolderSelector, setShowFolderSelector] = useState(false);
const { data: { noGit: noGitSetup, remotes } = { noGit: true, remotes: [] }, isLoading: isLoadingGitStatus } =
- useGetGitStatusQuery();
+ useGetGitStatusQuery(undefined, { skip: workspaceMode === 'github' });
let gitInfoContent: React.ReactNode | null = null;
if (isLoadingGitStatus) {
@@ -73,48 +93,78 @@ export const SettingsBox: FC = ({ settings, onUpdateSettings }
updateSettings({ docRootPath: selectedFolderPath });
};
+ const switchWorkspaceMode = async (mode: WorkspaceMode) => {
+ if (mode === workspaceMode) return;
+ if (isDirty && currentDocType === 'workspace') {
+ const shouldSave = await confirm({ message: 'Save the current document before switching workspace modes?' });
+ if (!shouldSave) return;
+ await saveDoc();
+ }
+ dispatch(updateTabs([]));
+ void navigate('/purePage');
+ dispatch(setWorkspaceMode(mode));
+ };
+
return (
-
-
- {
- setWorkspace(e.target.value);
- updateSettings({ docRootPath: e.target.value });
- }}
- />
- {
- setShowFolderSelector(true);
- }}
- >
-
-
-
-
-
-
-
-
- {
- const newIgnoreDirs = e.value ?? [];
- setIgnoreDirs(newIgnoreDirs);
- updateSettings({ ignoreDirs: newIgnoreDirs });
+
+ {
+ if (event.value == null) return;
+ void switchWorkspaceMode(event.value as WorkspaceMode);
}}
/>
+ {workspaceMode === 'github' ? (
+
+ ) : (
+ <>
+
+
+
+ {
+ setWorkspace(e.target.value);
+ updateSettings({ docRootPath: e.target.value });
+ }}
+ />
+ {
+ setShowFolderSelector(true);
+ }}
+ >
+
+
+
+
+
+
+
+
+ {
+ const newIgnoreDirs = e.value ?? [];
+ setIgnoreDirs(newIgnoreDirs);
+ updateSettings({ ignoreDirs: newIgnoreDirs });
+ }}
+ />
+
+ >
+ )}
);
};
diff --git a/client/src/components/Sidebar/Sidebar.scss b/client/src/components/Sidebar/Sidebar.scss
index 7940ff5..561efb0 100644
--- a/client/src/components/Sidebar/Sidebar.scss
+++ b/client/src/components/Sidebar/Sidebar.scss
@@ -7,9 +7,34 @@
border: 1px solid transparent;
border-right-color: $panelBorderColor;
padding: 18px 0 0 0;
- .icon-wrapper {
+ > .icon-wrapper,
+ .sidebar-git-control {
margin-bottom: 20px;
}
+ .sidebar-git-control {
+ position: relative;
+ width: 100%;
+ .icon-wrapper {
+ margin-bottom: 0;
+ }
+ }
+ .git-change-badge {
+ position: absolute;
+ top: -8px;
+ left: calc(50% + 5px);
+ z-index: 2;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: #007acc;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 16px;
+ text-align: center;
+ pointer-events: none;
+ }
}
.sidebar-overlay {
diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx
index f1af0fa..ef2c7f0 100644
--- a/client/src/components/Sidebar/Sidebar.tsx
+++ b/client/src/components/Sidebar/Sidebar.tsx
@@ -2,6 +2,7 @@ import GitFlow from '@mui/icons-material/CommitOutlined';
import MenuClose from '@mui/icons-material/MenuOpenOutlined';
import MenuOpen from '@mui/icons-material/MenuOutlined';
import SettingIcon from '@mui/icons-material/SettingsOutlined';
+import { Badge } from 'primereact/badge';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { OverlayPanel } from 'primereact/overlaypanel';
@@ -10,13 +11,17 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { GitBox } from '@/components/GitBox/GitBox';
+import { GitHubChangesBox } from '@/components/GitBox/GitHubChangesBox';
import { Icon } from '@/components/Icon/Icon';
import { SettingsBox } from '@/components/Settings/Settings';
+import { useGetGitStatusQuery } from '@/redux-api/git';
import { Settings, useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings';
import { updateTabs } from '@/redux-feature/curDocSlice';
import { clearAllDrafts } from '@/redux-feature/draftsSlice';
+import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice';
import { updateGlobalOpts, selectGlobalOpts, selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice';
import ErrorBoundary from '@/utils/ErrorBoundary/ErrorBoundary';
+import { useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime';
import Toast from '@/utils/Toast';
import { isEqual, nextTick } from '@/utils/utils';
@@ -27,12 +32,22 @@ export const Sidebar: FC = () => {
const [settingsLoading, setSettingsLoading] = useState(false);
const [newSettings, setNewSettings] = useState({});
- const { data: settings } = useGetSettingsQuery();
- const [updateSettings] = useUpdateSettingsMutation();
-
+ const workspaceMode = useSelector(selectWorkspaceMode);
+ const githubWorkspace = useGitHubWorkspaceSnapshot();
const { menuCollapse } = useSelector(selectGlobalOpts);
const serverStatus = useSelector(selectServerStatus);
const isServerNotConnected = serverStatus === ServerStatus.CANNOT_CONNECT;
+ const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' });
+ const { data: localGitStatus } = useGetGitStatusQuery(undefined, {
+ skip: workspaceMode === 'github' || isServerNotConnected,
+ });
+ const [updateSettings] = useUpdateSettingsMutation();
+ const githubChangeCount =
+ workspaceMode === 'github' ? [...githubWorkspace.unstagedChanges, ...githubWorkspace.stagedChanges].length : 0;
+ const localChangeCount = isServerNotConnected
+ ? 0
+ : [...(localGitStatus?.workspace ?? []), ...(localGitStatus?.staged ?? [])].length;
+ const gitChangeCount = workspaceMode === 'github' ? githubChangeCount : localChangeCount;
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -52,6 +67,10 @@ export const Sidebar: FC = () => {
};
const handleConfirmSettings = async () => {
+ if (workspaceMode === 'github') {
+ setSettingsShow(false);
+ return;
+ }
try {
setSettingsLoading(true);
@@ -91,7 +110,7 @@ export const Sidebar: FC = () => {
icon={menuCollapse ? MenuOpen : MenuClose}
toolTipContent={menuCollapse ? 'show menu' : 'hide menu'}
toolTipPosition="right"
- disabled={isServerNotConnected}
+ disabled={workspaceMode === 'local' && isServerNotConnected}
onClick={() => {
dispatch(
updateGlobalOpts({
@@ -101,19 +120,20 @@ export const Sidebar: FC = () => {
);
}}
/>
-
+
+
+ {gitChangeCount > 0 && }
+
-
-
-
+ {workspaceMode === 'github' ? : }
{
icon={SettingIcon}
toolTipContent="Settings"
toolTipPosition="right"
- disabled={isServerNotConnected}
onClick={() => {
setSettingsShow(true);
}}
@@ -130,12 +149,12 @@ export const Sidebar: FC = () => {
header={⚙️ Settings
}
footer={
-
+ {workspaceMode === 'local' &&
}
{
void handleConfirmSettings();
}}
diff --git a/client/src/constants.ts b/client/src/constants.ts
index 491f827..95b616a 100644
--- a/client/src/constants.ts
+++ b/client/src/constants.ts
@@ -1,9 +1,49 @@
+/** Application version injected by the build configuration. */
export const APP_VERSION = __VERSION__;
-/** /repo-name/ or / */
+
+/** GitHub Pages base path, such as `/repo-name/` or `/`. */
export const GITHUB_PAGES_BASE_PATH = __GITHUB_PAGES_BASE_PATH__;
-/** 3024 or 7024*/
+
+/** Local server port injected by the build; falsy for the hosted online build. */
export const SERVER_PORT = __SERVER_PORT__;
+/** Whether the client is connected to the bundled local server. */
export const ONLINE_MODE = Boolean(SERVER_PORT);
+/** Base URL used for requests to the local Markdown Editor API. */
export const SERVER_BASE_URL = ONLINE_MODE ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api';
+
+/** Directories excluded from GitHub workspace document discovery by default. */
+export const DEFAULT_IGNORE_DIRS = ['imgs', 'node_modules', 'dist'];
+
+/** IndexedDB database containing persisted GitHub workspace state. */
+export const GITHUB_WORKSPACE_DATABASE_NAME = 'markdown-editor-github-workspaces';
+
+/** IndexedDB object store containing version-one GitHub workspace records. */
+export const GITHUB_WORKSPACE_STORE_NAME = 'workspaces-v1';
+
+/** Repository-relative file used to persist workspace settings. */
+export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json';
+
+/** Local-storage key containing the active workspace mode. */
+export const WORKSPACE_MODE_STORAGE_KEY = 'workspace-mode';
+
+/** Local-storage key containing the selected GitHub workspace configuration. */
+export const GITHUB_WORKSPACE_CONFIG_STORAGE_KEY = 'github-workspace-config';
+
+/** Maximum size GitHub accepts for a regular repository file: 100 MiB. */
+// eslint-disable-next-line @typescript-eslint/no-magic-numbers
+export const GITHUB_MAX_REPOSITORY_FILE_SIZE_BYTES = 100 * 1024 * 1024;
+
+/** Image file extensions supported by GitHub workspace upload and management. */
+export const GITHUB_SUPPORTED_IMAGE_EXTENSIONS: ReadonlySet = new Set([
+ 'avif',
+ 'bmp',
+ 'gif',
+ 'ico',
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'svg',
+ 'webp',
+]);
diff --git a/client/src/redux-api/git.ts b/client/src/redux-api/git.ts
index 237adc6..4e9bb3d 100644
--- a/client/src/redux-api/git.ts
+++ b/client/src/redux-api/git.ts
@@ -52,7 +52,7 @@ const gitApi = docsApi.injectEndpoints({
method: 'POST',
body: restoreBody,
}),
- invalidatesTags: () => ['GitStatus', 'Menu', 'Article'],
+ invalidatesTags: (_result, _error, { staged }) => (staged ? ['GitStatus'] : ['GitStatus', 'Menu', 'Article']),
transformResponse,
transformErrorResponse,
}),
diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts
new file mode 100644
index 0000000..33c9050
--- /dev/null
+++ b/client/src/redux-api/github.ts
@@ -0,0 +1,619 @@
+/* eslint-disable @typescript-eslint/init-declarations */
+/* eslint-disable @typescript-eslint/naming-convention */
+/* eslint-disable @typescript-eslint/no-magic-numbers */
+/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
+import { GitMode } from '@markdown-editor/github-workspace';
+import { Octokit } from '@octokit/rest';
+import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react';
+
+import type { GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice';
+import type {
+ PublishPlan,
+ PublishTreeEntry,
+ RemoteDirectoryPage,
+ RemoteDirectoryRequest,
+ RemoteWorkspaceSnapshot,
+ WorkspaceDescriptor,
+} from '@markdown-editor/github-workspace';
+
+import { DEFAULT_IGNORE_DIRS, WORKSPACE_SETTINGS_PATH } from '@/constants';
+import { getGitHubAccessToken } from '@/utils/hooks/githubAuthHooks';
+
+export interface GitHubRepositoryOption {
+ owner: string;
+ name: string;
+ fullName: string;
+ defaultBranch: string;
+ private: boolean;
+}
+
+export type GitHubWorkspaceSnapshot = RemoteWorkspaceSnapshot;
+
+export interface CreateGitHubRepositoryPayload {
+ name: string;
+ description?: string;
+ private: boolean;
+ docsRoot: string;
+}
+
+export interface PublishGitHubWorkspacePayload {
+ config: WorkspaceDescriptor;
+ plan: PublishPlan;
+ title: string;
+ body: string;
+}
+
+export interface GitHubWorkspaceSettings {
+ docsRoot: string;
+ ignoreDirs: string[];
+ schemaVersion: number;
+}
+
+export interface GitHubCodeSearchItem {
+ name: string;
+ path: string;
+ sha: string;
+}
+
+export interface GitHubCodeSearchPage {
+ items: GitHubCodeSearchItem[];
+ totalCount: number;
+ incompleteResults: boolean;
+ nextPage?: number;
+}
+
+export interface GitHubApiError {
+ message: string;
+ rateLimitReset?: number;
+}
+
+export interface GitHubDirectoryRequest extends RemoteDirectoryRequest {
+ owner: string;
+ repo: string;
+}
+
+let octokitToken = '';
+let octokitClient: Octokit | null = null;
+
+const getOctokit = () => {
+ const token = getGitHubAccessToken();
+ if (!token) throw new Error('Sign in to GitHub to use this workspace.');
+ if (!octokitClient || octokitToken !== token) {
+ octokitToken = token;
+ octokitClient = new Octokit({ auth: token, userAgent: 'markdown-editor' });
+ }
+ return octokitClient;
+};
+
+const getErrorMessage = (error: unknown) => {
+ if (error instanceof Error) return error.message;
+ return 'GitHub request failed.';
+};
+
+const DEFAULT_RATE_LIMIT_WAIT_MS = 60_000;
+
+const getGitHubApiError = (error: unknown): GitHubApiError => {
+ const requestError = error as {
+ status?: number;
+ response?: { headers?: Record };
+ };
+ const response = requestError.response;
+ const reset = response?.headers?.['x-ratelimit-reset'];
+ const remaining = response?.headers?.['x-ratelimit-remaining'];
+ const retryAfter = response?.headers?.['retry-after'];
+ const isRateLimited =
+ requestError.status === 429 || (requestError.status === 403 && (remaining === '0' || retryAfter !== undefined));
+ const headerRateLimitReset = retryAfter !== undefined ? Date.now() + Number(retryAfter) * 1000 : Number(reset) * 1000;
+ const rateLimitReset = Number.isFinite(headerRateLimitReset)
+ ? headerRateLimitReset
+ : Date.now() + DEFAULT_RATE_LIMIT_WAIT_MS;
+ return {
+ message: getErrorMessage(error),
+ rateLimitReset: isRateLimited ? rateLimitReset : undefined,
+ };
+};
+
+const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, '');
+
+const quoteSearchValue = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+
+// GitHub's REST endpoint still uses the legacy code-search syntax. Keep simple
+// terms and paths bare (for example, `test path:docs`) and quote only values
+// that cannot safely be emitted as a single token.
+const formatSearchTerm = (value: string) => (/^[a-zA-Z0-9_-]+$/.test(value) ? value : quoteSearchValue(value));
+const formatSearchPath = (value: string) => (/^[a-zA-Z0-9_./-]+$/.test(value) ? value : quoteSearchValue(value));
+
+const CODE_SEARCH_PAGE_SIZE = 100;
+const CODE_SEARCH_RESULT_LIMIT = 1000;
+
+const requestGitTree = async (
+ config: Pick,
+ treeSha: string,
+ recursive = false,
+) => {
+ const octokit = getOctokit();
+ const response = await octokit.request('GET /repos/{owner}/{repo}/git/trees/{tree_sha}', {
+ owner: config.owner,
+ repo: config.repo,
+ tree_sha: treeSha,
+ recursive: recursive ? '1' : undefined,
+ });
+ return response.data;
+};
+
+const getGitTree = async (config: Pick, treeSha: string) => {
+ const tree = await requestGitTree(config, treeSha);
+ if (tree.truncated) {
+ throw new Error('This GitHub directory is too large to load safely.');
+ }
+ return tree.tree;
+};
+
+const toRemoteEntries = (
+ directoryPath: string,
+ entries: Awaited>,
+): RemoteDirectoryPage['entries'] =>
+ entries
+ .filter((entry) => entry.path && entry.mode && entry.type && entry.sha)
+ .map((entry) => ({
+ path: [directoryPath, entry.path].filter(Boolean).join('/'),
+ mode: entry.mode as GitMode,
+ type: entry.type as RemoteDirectoryPage['entries'][number]['type'],
+ sha: entry.sha,
+ size: entry.size,
+ }));
+
+const getCommitTree = async (config: Pick) => {
+ const octokit = getOctokit();
+ const refResponse = await octokit.request('GET /repos/{owner}/{repo}/git/ref/{ref}', {
+ owner: config.owner,
+ repo: config.repo,
+ ref: `heads/${config.branch}`,
+ });
+ const commitSha = refResponse.data.object.sha;
+ const commitResponse = await octokit.request('GET /repos/{owner}/{repo}/git/commits/{commit_sha}', {
+ owner: config.owner,
+ repo: config.repo,
+ commit_sha: commitSha,
+ });
+ return { baseCommitSha: commitSha, baseTreeSha: commitResponse.data.tree.sha };
+};
+
+const getSnapshot = async (config: WorkspaceDescriptor): Promise => {
+ const { baseCommitSha, baseTreeSha } = await getCommitTree(config);
+ const directoryPath = cleanPath(config.docsRoot);
+ let directoryTreeSha: string | null = baseTreeSha;
+ let traversedPath = '';
+
+ for (const segment of directoryPath.split('/').filter(Boolean)) {
+ const entries = await getGitTree(config, directoryTreeSha);
+ const directory = entries.find((entry) => entry.path === segment);
+ traversedPath = [traversedPath, segment].filter(Boolean).join('/');
+ if (!directory) {
+ return { baseCommitSha, baseTreeSha, directoryPath, directoryTreeSha: null, entries: [], complete: true };
+ }
+ if (directory.type !== 'tree' || !directory.sha) {
+ throw new Error(`The configured docs root ${traversedPath} is not a directory.`);
+ }
+ directoryTreeSha = directory.sha;
+ }
+
+ if (!directoryTreeSha) {
+ return { baseCommitSha, baseTreeSha, directoryPath, directoryTreeSha, entries: [], complete: true };
+ }
+
+ const recursiveTree = await requestGitTree(config, directoryTreeSha, true);
+ if (!recursiveTree.truncated) {
+ return {
+ baseCommitSha,
+ baseTreeSha,
+ directoryPath,
+ directoryTreeSha,
+ entries: toRemoteEntries(directoryPath, recursiveTree.tree),
+ complete: true,
+ };
+ }
+
+ // GitHub does not paginate truncated recursive trees. Restart from the directory's
+ // direct children and let the existing subtree loader fetch descendants on demand.
+ const entries = await getGitTree(config, directoryTreeSha);
+ return {
+ baseCommitSha,
+ baseTreeSha,
+ directoryPath,
+ directoryTreeSha,
+ entries: toRemoteEntries(directoryPath, entries),
+ complete: false,
+ };
+};
+
+const getDirectoryPage = async ({
+ owner,
+ repo,
+ baseCommitSha,
+ baseTreeSha,
+ directoryPath,
+ directoryTreeSha,
+}: GitHubDirectoryRequest): Promise => ({
+ baseCommitSha,
+ baseTreeSha,
+ directoryPath: cleanPath(directoryPath),
+ directoryTreeSha,
+ entries: toRemoteEntries(cleanPath(directoryPath), await getGitTree({ owner, repo }, directoryTreeSha)),
+});
+
+interface CommitTreeOptions {
+ baseCommitSha: string;
+ baseTreeSha: string;
+ config: Pick;
+ entries: PublishTreeEntry[];
+ message: string;
+}
+
+/**
+ * Writes a set of file changes as one commit and advances the configured branch.
+ * Entry content creates a new blob, an existing SHA is reused, and a null SHA without content deletes the path.
+ */
+const commitTreeToBranch = async ({ baseCommitSha, baseTreeSha, config, entries, message }: CommitTreeOptions) => {
+ const octokit = getOctokit();
+ // Materialize new content as blobs while preserving existing blobs and deletion markers.
+ const treeEntries = await Promise.all(
+ entries.map(async (entry) => {
+ if (entry.content === undefined) {
+ if (entry.sha === null) return { path: entry.path, mode: entry.mode, type: entry.type, sha: null };
+ if (!entry.sha) throw new Error(`No content is available for ${entry.path}.`);
+ return { path: entry.path, mode: entry.mode, type: entry.type, sha: entry.sha };
+ }
+ const blob = await octokit.request('POST /repos/{owner}/{repo}/git/blobs', {
+ owner: config.owner,
+ repo: config.repo,
+ content: entry.content,
+ encoding: entry.contentEncoding ?? 'utf-8',
+ });
+ return { path: entry.path, mode: entry.mode, type: entry.type, sha: blob.data.sha };
+ }),
+ );
+ // Apply the resolved entries to the base tree, then create a commit on top of the expected parent.
+ const tree = await octokit.request('POST /repos/{owner}/{repo}/git/trees', {
+ owner: config.owner,
+ repo: config.repo,
+ base_tree: baseTreeSha,
+ tree: treeEntries,
+ });
+ const commit = await octokit.request('POST /repos/{owner}/{repo}/git/commits', {
+ owner: config.owner,
+ repo: config.repo,
+ message,
+ tree: tree.data.sha,
+ parents: [baseCommitSha],
+ });
+ // Move the branch only when GitHub can perform a non-forced update.
+ await octokit.request('PATCH /repos/{owner}/{repo}/git/refs/{ref}', {
+ owner: config.owner,
+ repo: config.repo,
+ ref: `heads/${config.branch}`,
+ sha: commit.data.sha,
+ force: false,
+ });
+};
+
+const hasWorkspaceSettings = async (
+ config: Pick,
+): Promise => {
+ try {
+ const response = await getOctokit().rest.repos.getContent({
+ owner: config.owner,
+ repo: config.repo,
+ path: WORKSPACE_SETTINGS_PATH,
+ ref: config.branch,
+ });
+ return !Array.isArray(response.data) && response.data.type === 'file';
+ } catch (error) {
+ if ((error as { status?: number }).status === 404) return false;
+ throw error;
+ }
+};
+
+const initializeWorkspace = async (
+ config: Pick,
+): Promise => {
+ const octokit = getOctokit();
+ const docsRoot = config.docsRoot.replace(/^\/+|\/+$/g, '');
+ const settingsContent = `${JSON.stringify({ schemaVersion: 1, docsRoot, ignoreDirs: config.ignoreDirs }, null, 2)}\n`;
+ let snapshot: GitHubWorkspaceSnapshot;
+ let isInitializing: boolean;
+ try {
+ snapshot = await getSnapshot(config);
+ isInitializing = !(await hasWorkspaceSettings(config));
+ } catch (error) {
+ const status = (error as { status?: number }).status;
+ if (status !== 404 && status !== 409) throw error;
+ isInitializing = true;
+ await octokit.request('PUT /repos/{owner}/{repo}/contents/{path}', {
+ owner: config.owner,
+ repo: config.repo,
+ path: WORKSPACE_SETTINGS_PATH,
+ message: 'Initialize Markdown Editor workspace',
+ content: window.btoa(settingsContent),
+ });
+ snapshot = await getSnapshot(config);
+ }
+ await commitTreeToBranch({
+ config,
+ baseCommitSha: snapshot.baseCommitSha,
+ baseTreeSha: snapshot.baseTreeSha,
+ entries: [
+ {
+ path: WORKSPACE_SETTINGS_PATH,
+ mode: GitMode.File,
+ type: 'blob',
+ sha: null,
+ content: settingsContent,
+ },
+ ],
+ message: `${isInitializing ? 'Initialize' : 'Update'} Markdown Editor workspace`,
+ });
+ return getSnapshot(config);
+};
+
+const decodeBase64Utf8 = (content: string) => {
+ const binary = window.atob(content.replace(/\n/g, ''));
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+};
+
+const encodeUtf8Base64 = (content: string) => {
+ const bytes = new TextEncoder().encode(content);
+ let binary = '';
+ const chunkSize = 0x8000;
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
+ }
+ return window.btoa(binary);
+};
+
+export const getGitHubBlobBase64 = async (owner: string, repo: string, sha: string) => {
+ const response = await getOctokit().request('GET /repos/{owner}/{repo}/git/blobs/{file_sha}', {
+ owner,
+ repo,
+ file_sha: sha,
+ });
+ return response.data.encoding === 'base64'
+ ? response.data.content.replace(/\n/g, '')
+ : encodeUtf8Base64(response.data.content);
+};
+
+export const loadGitHubDirectory = async (request: GitHubDirectoryRequest) => getDirectoryPage(request);
+
+export const listAccessibleGitHubRepositories = async (): Promise => {
+ const octokit = getOctokit();
+ const installations = await octokit.paginate('GET /user/installations', { per_page: 100 });
+ const repositoryPages = await Promise.all(
+ installations
+ .filter((installation) => installation.permissions.contents === 'write')
+ .map(async (installation) =>
+ octokit.paginate('GET /user/installations/{installation_id}/repositories', {
+ installation_id: installation.id,
+ per_page: 100,
+ }),
+ ),
+ );
+ const repositories = [...new Map(repositoryPages.flat().map((repository) => [repository.id, repository])).values()];
+ return repositories
+ .filter((repository) => repository.permissions?.push || repository.permissions?.admin)
+ .map((repository) => ({
+ owner: repository.owner.login,
+ name: repository.name,
+ fullName: repository.full_name,
+ defaultBranch: repository.default_branch,
+ private: repository.private,
+ }));
+};
+
+export const githubApi = createApi({
+ reducerPath: 'githubApi',
+ baseQuery: fakeBaseQuery(),
+ tagTypes: ['Branches', 'Repositories', 'Workspace'],
+ endpoints: (builder) => ({
+ listGitHubRepositories: builder.query({
+ queryFn: async () => {
+ try {
+ return { data: await listAccessibleGitHubRepositories() };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ providesTags: ['Repositories'],
+ }),
+ listGitHubBranches: builder.query({
+ queryFn: async ({ owner, repo }) => {
+ try {
+ const octokit = getOctokit();
+ const branches = await octokit.paginate(octokit.rest.repos.listBranches, { owner, repo, per_page: 100 });
+ return { data: branches.map((branch) => branch.name) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ providesTags: ['Branches'],
+ }),
+ getGitHubWorkspaceSettings: builder.query<
+ GitHubWorkspaceSettings | null,
+ { owner: string; repo: string; branch: string }
+ >({
+ queryFn: async ({ owner, repo, branch }) => {
+ try {
+ const response = await getOctokit().rest.repos.getContent({
+ owner,
+ repo,
+ path: WORKSPACE_SETTINGS_PATH,
+ ref: branch,
+ });
+ if (Array.isArray(response.data) || response.data.type !== 'file' || !('content' in response.data)) {
+ return { data: null };
+ }
+ const value = JSON.parse(decodeBase64Utf8(response.data.content)) as {
+ docsRoot?: unknown;
+ ignoreDirs?: unknown;
+ schemaVersion?: unknown;
+ };
+ if (value.schemaVersion !== 1 || typeof value.docsRoot !== 'string') return { data: null };
+ if (
+ value.ignoreDirs !== undefined &&
+ (!Array.isArray(value.ignoreDirs) || value.ignoreDirs.some((item) => typeof item !== 'string'))
+ ) {
+ return { data: null };
+ }
+ return {
+ data: {
+ schemaVersion: 1,
+ docsRoot: value.docsRoot,
+ ignoreDirs: value.ignoreDirs === undefined ? [...DEFAULT_IGNORE_DIRS] : (value.ignoreDirs as string[]),
+ },
+ };
+ } catch (error) {
+ if ((error as { status?: number }).status === 404) return { data: null };
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ }),
+ loadGitHubWorkspace: builder.query({
+ queryFn: async (config) => {
+ try {
+ return { data: await getSnapshot(config) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ providesTags: ['Workspace'],
+ }),
+ getGitHubDirectory: builder.query({
+ queryFn: async (request) => {
+ try {
+ return { data: await getDirectoryPage(request) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ }),
+ getGitHubBlob: builder.query({
+ queryFn: async ({ owner, repo, sha }) => {
+ try {
+ return { data: decodeBase64Utf8(await getGitHubBlobBase64(owner, repo, sha)) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ }),
+ searchGitHubCode: builder.query<
+ GitHubCodeSearchPage,
+ { owner: string; repo: string; docsRoot: string; query: string; page?: number }
+ >({
+ queryFn: async ({ owner, repo, docsRoot, query, page = 1 }) => {
+ try {
+ const qualifiers = [formatSearchTerm(query.trim()), 'in:file', `repo:${owner}/${repo}`, 'extension:md'];
+ const normalizedDocsRoot = cleanPath(docsRoot);
+ if (normalizedDocsRoot) qualifiers.push(`path:${formatSearchPath(normalizedDocsRoot)}`);
+ // TODO: https://github.com/orgs/community/discussions/206576
+ const response = await getOctokit().request('GET /search/code', {
+ q: qualifiers.join(' '),
+ page,
+ per_page: CODE_SEARCH_PAGE_SIZE,
+ headers: {
+ accept: 'application/vnd.github+json',
+ },
+ });
+ const reachableResults = Math.min(response.data.total_count, CODE_SEARCH_RESULT_LIMIT);
+ return {
+ data: {
+ items: response.data.items.map((item) => ({ name: item.name, path: item.path, sha: item.sha })),
+ totalCount: response.data.total_count,
+ incompleteResults: response.data.incomplete_results,
+ nextPage: page * CODE_SEARCH_PAGE_SIZE < reachableResults ? page + 1 : undefined,
+ },
+ };
+ } catch (error) {
+ return { error: getGitHubApiError(error) };
+ }
+ },
+ }),
+ createGitHubRepository: builder.mutation<
+ { config: GitHubWorkspaceConfig; snapshot: GitHubWorkspaceSnapshot },
+ CreateGitHubRepositoryPayload
+ >({
+ queryFn: async (payload) => {
+ try {
+ const response = await getOctokit().rest.repos.createForAuthenticatedUser({
+ name: payload.name,
+ description: payload.description,
+ private: payload.private,
+ auto_init: true,
+ });
+ const partialConfig = {
+ owner: response.data.owner.login,
+ repo: response.data.name,
+ branch: response.data.default_branch,
+ docsRoot: payload.docsRoot,
+ ignoreDirs: [...DEFAULT_IGNORE_DIRS],
+ };
+ const snapshot = await initializeWorkspace(partialConfig);
+ return {
+ data: {
+ config: partialConfig,
+ snapshot,
+ },
+ };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ invalidatesTags: ['Repositories'],
+ }),
+ initializeGitHubWorkspace: builder.mutation({
+ queryFn: async (config) => {
+ try {
+ return { data: await initializeWorkspace(config) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ invalidatesTags: ['Workspace'],
+ }),
+ publishGitHubWorkspace: builder.mutation({
+ queryFn: async ({ config, plan, title, body }) => {
+ try {
+ const current = await getCommitTree(config);
+ if (current.baseCommitSha !== plan.baseCommitSha) {
+ throw new Error(
+ 'The selected branch changed on GitHub. Rebase or discard local changes before publishing.',
+ );
+ }
+ await commitTreeToBranch({
+ config,
+ baseCommitSha: plan.baseCommitSha,
+ baseTreeSha: plan.baseTreeSha,
+ entries: plan.entries,
+ message: body.trim() ? `${title}\n\n${body}` : title,
+ });
+ return { data: await getSnapshot(config) };
+ } catch (error) {
+ return { error: { message: getErrorMessage(error) } };
+ }
+ },
+ invalidatesTags: ['Workspace'],
+ }),
+ }),
+});
+
+export const {
+ useCreateGitHubRepositoryMutation,
+ useLazyGetGitHubDirectoryQuery,
+ useGetGitHubBlobQuery,
+ useGetGitHubWorkspaceSettingsQuery,
+ useInitializeGitHubWorkspaceMutation,
+ useListGitHubBranchesQuery,
+ useListGitHubRepositoriesQuery,
+ useLoadGitHubWorkspaceQuery,
+ usePublishGitHubWorkspaceMutation,
+ useLazySearchGitHubCodeQuery,
+} = githubApi;
diff --git a/client/src/redux-api/img.ts b/client/src/redux-api/img.ts
index 81e71d3..eeff727 100644
--- a/client/src/redux-api/img.ts
+++ b/client/src/redux-api/img.ts
@@ -4,7 +4,7 @@ import { transformResponse, transformErrorResponse } from './interceptor';
export interface ImgListItem {
fileName: string;
url: string;
- createdTime: number;
+ createdTime?: number;
}
export interface ImgDataType {
diff --git a/client/src/redux-feature/curDocSlice.ts b/client/src/redux-feature/curDocSlice.ts
index 63fe4ec..fee9722 100644
--- a/client/src/redux-feature/curDocSlice.ts
+++ b/client/src/redux-feature/curDocSlice.ts
@@ -43,6 +43,7 @@ export interface CurDocState {
}
export type CurDocUpdatePayLoad = Partial & {
+ syncTab?: boolean;
type: DocType;
};
@@ -76,6 +77,7 @@ export const curDocSlice = createSlice({
type = state.type,
contentIdent = state.contentIdent,
title = state.title,
+ syncTab = true,
} = action.payload;
// cant do this...
@@ -88,20 +90,22 @@ export const curDocSlice = createSlice({
state.headings = headings;
state.scrollTop = scrollTop;
- // update active tab
- // clear all first
- state.tabs.forEach((tab) => (tab.active = false));
-
- const curTab = state.tabs.find((tab) => tab.ident === contentIdent) as Tab | undefined;
- if (curTab) {
- curTab.active = true;
- } else {
- state.tabs.push({ active: true, scroll: scrollTop, type, ident: contentIdent, title });
+ if (syncTab && contentIdent) {
+ // update active tab
+ // clear all first
+ state.tabs.forEach((tab) => (tab.active = false));
+
+ const curTab = state.tabs.find((tab) => tab.ident === contentIdent) as Tab | undefined;
+ if (curTab) {
+ curTab.active = true;
+ } else {
+ state.tabs.push({ active: true, scroll: scrollTop, type, ident: contentIdent, title });
+ }
+
+ // update localStorage
+ const { setStore: storeTabs } = localStore('tabs');
+ storeTabs(JSON.stringify(state.tabs));
}
-
- // update localStorage
- const { setStore: storeTabs } = localStore('tabs');
- storeTabs(JSON.stringify(state.tabs));
},
updateIsDirty: (state, action: PayloadAction<{ isDirty: boolean }>) => {
diff --git a/client/src/redux-feature/githubWorkspaceSlice.ts b/client/src/redux-feature/githubWorkspaceSlice.ts
new file mode 100644
index 0000000..474c1fe
--- /dev/null
+++ b/client/src/redux-feature/githubWorkspaceSlice.ts
@@ -0,0 +1,76 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+import type { RootState } from '@/store';
+import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace';
+
+import { DEFAULT_IGNORE_DIRS, GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, WORKSPACE_MODE_STORAGE_KEY } from '@/constants';
+
+export type WorkspaceMode = 'github' | 'local';
+export interface GitHubWorkspaceConfig extends WorkspaceDescriptor {
+ ignoreDirs: string[];
+}
+
+interface GitHubWorkspaceState {
+ mode: WorkspaceMode;
+ config: GitHubWorkspaceConfig;
+}
+
+export const emptyConfig: GitHubWorkspaceConfig = {
+ owner: '',
+ repo: '',
+ branch: '',
+ docsRoot: 'docs',
+ ignoreDirs: [...DEFAULT_IGNORE_DIRS],
+};
+
+const readMode = (): WorkspaceMode =>
+ window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY) === 'github' ? 'github' : 'local';
+
+const readConfig = (): GitHubWorkspaceConfig => {
+ try {
+ const storedConfig = window.localStorage.getItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY);
+ if (!storedConfig) return emptyConfig;
+ const parsed = JSON.parse(storedConfig) as Partial;
+ return {
+ owner: parsed.owner ?? '',
+ repo: parsed.repo ?? '',
+ branch: parsed.branch ?? '',
+ docsRoot: parsed.docsRoot ?? 'docs',
+ ignoreDirs: Array.isArray(parsed.ignoreDirs)
+ ? parsed.ignoreDirs.filter((value): value is string => typeof value === 'string')
+ : [...DEFAULT_IGNORE_DIRS],
+ };
+ } catch {
+ return emptyConfig;
+ }
+};
+
+export const getGitHubWorkspaceKey = (config: WorkspaceDescriptor) =>
+ [config.owner, config.repo, config.branch, config.docsRoot].join('/');
+
+const initialState: GitHubWorkspaceState = {
+ mode: readMode(),
+ config: readConfig(),
+};
+
+const githubWorkspaceSlice = createSlice({
+ name: 'githubWorkspace',
+ initialState,
+ reducers: {
+ setWorkspaceMode: (state, action: PayloadAction) => {
+ state.mode = action.payload;
+ window.localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, action.payload);
+ },
+ setGitHubWorkspaceConfig: (state, action: PayloadAction) => {
+ state.config = action.payload;
+ window.localStorage.setItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, JSON.stringify(action.payload));
+ },
+ },
+});
+
+export const { setGitHubWorkspaceConfig, setWorkspaceMode } = githubWorkspaceSlice.actions;
+export const selectGithubWorkspace = (state: RootState) => state.githubWorkspace;
+export const selectWorkspaceMode = (state: RootState) => state.githubWorkspace.mode;
+export const selectGitHubWorkspaceConfig = (state: RootState) => state.githubWorkspace.config;
+
+export default githubWorkspaceSlice.reducer;
diff --git a/client/src/store.ts b/client/src/store.ts
index 09e1f4f..c5f2288 100644
--- a/client/src/store.ts
+++ b/client/src/store.ts
@@ -1,8 +1,10 @@
import { configureStore } from '@reduxjs/toolkit';
import { docsApi } from './redux-api/docs';
+import { githubApi } from './redux-api/github';
import curDocReducer from './redux-feature/curDocSlice';
import draftsReducer from './redux-feature/draftsSlice';
+import githubWorkspaceReducer from './redux-feature/githubWorkspaceSlice';
import globalOptsReducer from './redux-feature/globalOptsSlice';
import operationMenuReducer from './redux-feature/operationMenuSlice';
@@ -11,13 +13,15 @@ export const store = configureStore({
globalOpts: globalOptsReducer,
curDoc: curDocReducer,
drafts: draftsReducer,
+ githubWorkspace: githubWorkspaceReducer,
operationMenu: operationMenuReducer,
[docsApi.reducerPath]: docsApi.reducer,
+ [githubApi.reducerPath]: githubApi.reducer,
},
// This middleware must be added as well - it manages cache lifetimes and expiration
// We need to keep all of the existing standard middleware like redux-thunk in the store setup,
// which leads to the use of concat
- middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(docsApi.middleware),
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(docsApi.middleware, githubApi.middleware),
});
// Infer the `RootState` and `AppDispatch` types from the store itself
diff --git a/client/src/theme/cyber.scss b/client/src/theme/cyber.scss
index 7a1dd07..3fbd1c8 100644
--- a/client/src/theme/cyber.scss
+++ b/client/src/theme/cyber.scss
@@ -14,53 +14,30 @@
@mixin cyber-striped-frame($background: rgba(255, 252, 246, 0.88)) {
background-color: $background;
background-repeat: no-repeat;
- background-image:
- linear-gradient(92deg, transparent 0 14%, var(--cyberWornEdgeColor) 14% 15%, transparent 15% 100%),
- repeating-linear-gradient(135deg, var(--cyberStripeColor) 0 2px, transparent 2px 6px),
- var(--cyberCornerFrame);
- background-size:
- 72px 100%,
- auto,
- var(--cyberCornerFrameSize);
- background-position:
- -8px 0,
- 0 0,
- var(--cyberCornerFramePosition);
+ background-image: linear-gradient(92deg, transparent 0 14%, var(--cyberWornEdgeColor) 14% 15%, transparent 15% 100%),
+ repeating-linear-gradient(135deg, var(--cyberStripeColor) 0 2px, transparent 2px 6px), var(--cyberCornerFrame);
+ background-size: 72px 100%, auto, var(--cyberCornerFrameSize);
+ background-position: -8px 0, 0 0, var(--cyberCornerFramePosition);
}
@mixin cyber-grid-surface($background: var(--backgroundColor)) {
background-color: $background;
- background-image:
- radial-gradient(circle at 12px 18px, var(--cyberPaperFleckColor) 0 0.8px, transparent 1px),
+ background-image: radial-gradient(circle at 12px 18px, var(--cyberPaperFleckColor) 0 0.8px, transparent 1px),
linear-gradient(112deg, transparent 0 34%, var(--cyberPaperScratchColor) 34% 35%, transparent 35% 100%),
linear-gradient(var(--gridLineColor) 1px, transparent 1px),
linear-gradient(90deg, var(--gridLineColor) 1px, transparent 1px);
- background-size:
- 37px 43px,
- 118px 86px,
- var(--cyberGridSize) var(--cyberGridSize),
+ background-size: 37px 43px, 118px 86px, var(--cyberGridSize) var(--cyberGridSize),
var(--cyberGridSize) var(--cyberGridSize);
- background-position:
- 3px 5px,
- 17px 11px,
- -1px -1px,
- -1px -1px;
+ background-position: 3px 5px, 17px 11px, -1px -1px, -1px -1px;
}
@mixin cyber-worn-surface-texture {
- background-image:
- radial-gradient(ellipse at 18% 16%, var(--cyberTeaStainColor) 0 10%, transparent 24%),
+ background-image: radial-gradient(ellipse at 18% 16%, var(--cyberTeaStainColor) 0 10%, transparent 24%),
radial-gradient(circle at 9px 13px, var(--cyberPaperFleckColor) 0 0.7px, transparent 1px),
linear-gradient(107deg, transparent 0 52%, var(--cyberPaperScratchColor) 52% 53%, transparent 53% 100%);
background-repeat: no-repeat, repeat, repeat;
- background-size:
- 180px 128px,
- 33px 37px,
- 96px 72px;
- background-position:
- -34px -22px,
- 2px 1px,
- 11px 17px;
+ background-size: 180px 128px, 33px 37px, 96px 72px;
+ background-position: -34px -22px, 2px 1px, 11px 17px;
}
@mixin cyber-panel-surface($background: var(--panelBackgroundColor)) {
@@ -100,8 +77,7 @@
--cyberPaperScratchColor: rgba(23, 20, 17, 0.035);
--cyberWornEdgeColor: rgba(126, 111, 91, 0.16);
--cyberTeaStainColor: rgba(127, 96, 48, 0.07);
- --cyberCornerFrame:
- linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
+ --cyberCornerFrame: linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
@@ -109,26 +85,14 @@
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor)),
linear-gradient(var(--cyberCornerColor), var(--cyberCornerColor));
- --cyberCornerFrameSize:
- var(--cyberCornerSize) var(--cyberCornerThickness),
- var(--cyberCornerThickness) var(--cyberCornerSize),
- var(--cyberCornerSize) var(--cyberCornerThickness),
- var(--cyberCornerThickness) var(--cyberCornerSize),
- var(--cyberCornerSize) var(--cyberCornerThickness),
- var(--cyberCornerThickness) var(--cyberCornerSize),
- var(--cyberCornerSize) var(--cyberCornerThickness),
+ --cyberCornerFrameSize: var(--cyberCornerSize) var(--cyberCornerThickness),
+ var(--cyberCornerThickness) var(--cyberCornerSize), var(--cyberCornerSize) var(--cyberCornerThickness),
+ var(--cyberCornerThickness) var(--cyberCornerSize), var(--cyberCornerSize) var(--cyberCornerThickness),
+ var(--cyberCornerThickness) var(--cyberCornerSize), var(--cyberCornerSize) var(--cyberCornerThickness),
var(--cyberCornerThickness) var(--cyberCornerSize);
- --cyberCornerFramePosition:
- left top,
- left top,
- right top,
- right top,
- left bottom,
- left bottom,
- right bottom,
+ --cyberCornerFramePosition: left top, left top, right top, right top, left bottom, left bottom, right bottom,
right bottom;
- --cyberGridPattern:
- linear-gradient(var(--gridLineColor) 1px, transparent 1px),
+ --cyberGridPattern: linear-gradient(var(--gridLineColor) 1px, transparent 1px),
linear-gradient(90deg, var(--gridLineColor) 1px, transparent 1px);
.milkdown {
@@ -157,7 +121,6 @@
}
}
-
.cyber {
color-scheme: light;
}
@@ -187,6 +150,13 @@
color: inherit;
}
+html.cyber .p-dropdown-panel .p-dropdown-header {
+ background-color: var(--panelBackgroundColor);
+ border-bottom: 1px solid var(--panelBorderColor);
+ color: var(--contentTextColor);
+ @include cyber-worn-surface-texture();
+}
+
.cyber .p-menu .p-menuitem-link:hover,
.cyber .p-contextmenu .p-menuitem-link:hover,
.cyber .p-dropdown-item:hover,
@@ -360,7 +330,6 @@ html.cyber .doc-search-container .search-trigger-shortcut {
border-radius: 0;
}
-
html.cyber .header-container .icon-wrapper .icon:hover::after,
html.cyber .app-footer .icon-wrapper .icon:hover::after,
html.cyber .sidebar-container .icon-wrapper .icon:hover::after,
@@ -492,7 +461,6 @@ html.cyber .p-toast .p-toast-icon-close:focus-visible {
outline-offset: 2px;
}
-
.cyber .header-container,
.cyber .open-tab-scroller,
.cyber .open-tab-container,
@@ -693,7 +661,7 @@ html.cyber .sidebar-overlay.p-overlaypanel .git-box .op-box {
border-bottom: 1px solid var(--panelBorderColor);
}
-html.cyber .sidebar-overlay.p-overlaypanel .git-box .op-box .icon-wrapper {
+html.cyber .sidebar-overlay.p-overlaypanel .git-box:not(.github-changes-box) .op-box .icon-wrapper {
margin-left: 0;
}
@@ -820,7 +788,6 @@ html.cyber .commit-msg-box .commit-msg-input:focus {
box-shadow: none;
}
-
.cyber .editor-container .doc-area {
@include cyber-grid-surface();
}
@@ -838,41 +805,16 @@ html.cyber .editor-box .milkdown .editor {
margin: 2rem auto 3rem;
padding: 2rem 3rem 4rem;
background-color: var(--editorCanvasColor);
- background-image:
- radial-gradient(ellipse at 14% 11%, var(--cyberTeaStainColor) 0 8%, transparent 22%),
+ background-image: radial-gradient(ellipse at 14% 11%, var(--cyberTeaStainColor) 0 8%, transparent 22%),
radial-gradient(circle at 13px 17px, var(--cyberPaperFleckColor) 0 0.7px, transparent 1px),
linear-gradient(109deg, transparent 0 44%, var(--cyberPaperScratchColor) 44% 45%, transparent 45% 100%),
linear-gradient(var(--gridLineColor) 1px, transparent 1px),
- linear-gradient(90deg, var(--gridLineColor) 1px, transparent 1px),
- var(--cyberCornerFrame);
- background-repeat:
- no-repeat,
- repeat,
- repeat,
- repeat,
- repeat,
- no-repeat,
- no-repeat,
- no-repeat,
- no-repeat,
- no-repeat,
- no-repeat,
- no-repeat,
- no-repeat;
- background-size:
- 260px 180px,
- 41px 47px,
- 132px 96px,
- var(--cyberGridSize) var(--cyberGridSize),
- var(--cyberGridSize) var(--cyberGridSize),
- var(--cyberCornerFrameSize);
- background-position:
- -42px -30px,
- 5px 7px,
- 19px 23px,
- -1px -1px,
- -1px -1px,
- var(--cyberCornerFramePosition);
+ linear-gradient(90deg, var(--gridLineColor) 1px, transparent 1px), var(--cyberCornerFrame);
+ background-repeat: no-repeat, repeat, repeat, repeat, repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
+ no-repeat, no-repeat, no-repeat;
+ background-size: 260px 180px, 41px 47px, 132px 96px, var(--cyberGridSize) var(--cyberGridSize),
+ var(--cyberGridSize) var(--cyberGridSize), var(--cyberCornerFrameSize);
+ background-position: -42px -30px, 5px 7px, 19px 23px, -1px -1px, -1px -1px, var(--cyberCornerFramePosition);
border: 1px solid var(--panelBorderColor);
box-shadow: var(--frameShadow);
}
diff --git a/client/src/utils/githubImages.ts b/client/src/utils/githubImages.ts
new file mode 100644
index 0000000..cc7bceb
--- /dev/null
+++ b/client/src/utils/githubImages.ts
@@ -0,0 +1,219 @@
+/* eslint-disable @typescript-eslint/no-magic-numbers */
+import { WORKSPACE_ASSET_DIRECTORY } from '@markdown-editor/core';
+
+import { GITHUB_MAX_REPOSITORY_FILE_SIZE_BYTES, GITHUB_SUPPORTED_IMAGE_EXTENSIONS } from '@/constants';
+import { getGitHubBlobBase64, loadGitHubDirectory } from '@/redux-api/github';
+import { getGitHubWorkspaceKey, type GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice';
+import { githubWorkspaceStore } from '@/utils/githubWorkspaceRuntime';
+
+interface CachedImageUrl {
+ objectUrl: string;
+ sourceKey: string;
+ workspaceKey: string;
+}
+
+type WorkspaceImageSource =
+ | { content: string; kind: 'local'; sourceKey: string }
+ | { kind: 'remote'; sha: string; sourceKey: string };
+
+const imageUrlCache = new Map();
+let activeWorkspaceKey = '';
+
+const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, '');
+
+export const getGitHubAssetsPath = (config: GitHubWorkspaceConfig) =>
+ [cleanPath(config.docsRoot), WORKSPACE_ASSET_DIRECTORY].filter(Boolean).join('/');
+
+/** Returns the repository-root-relative URL written into GitHub-mode Markdown. */
+export const getGitHubImageUrl = (config: GitHubWorkspaceConfig, fileName: string) =>
+ `/${[getGitHubAssetsPath(config), cleanPath(fileName)].filter(Boolean).join('/')}`;
+
+const getGitHubImageRepositoryPath = (config: GitHubWorkspaceConfig, url: string) => {
+ if (!url.startsWith('/')) return undefined;
+ const normalizedUrl = cleanPath(url);
+ const assetsPath = getGitHubAssetsPath(config);
+ const legacyAssetsPath = `${WORKSPACE_ASSET_DIRECTORY}/`;
+ const repositoryPath = normalizedUrl.startsWith(`${assetsPath}/`)
+ ? normalizedUrl
+ : normalizedUrl.startsWith(legacyAssetsPath)
+ ? [cleanPath(config.docsRoot), normalizedUrl].filter(Boolean).join('/')
+ : undefined;
+ if (!repositoryPath) return undefined;
+
+ const imagePath = repositoryPath.slice(assetsPath.length + 1);
+ if (!imagePath || imagePath.includes('/')) throw new Error(`Unsupported image path: ${url}`);
+ return repositoryPath;
+};
+
+export const isSupportedImagePath = (path: string) => {
+ const extension = path.split('.').at(-1)?.toLocaleLowerCase() ?? '';
+ return GITHUB_SUPPORTED_IMAGE_EXTENSIONS.has(extension);
+};
+
+const getImageMime = (path: string) => {
+ const extension = path.split('.').at(-1)?.toLocaleLowerCase();
+ switch (extension) {
+ case 'avif':
+ return 'image/avif';
+ case 'bmp':
+ return 'image/bmp';
+ case 'gif':
+ return 'image/gif';
+ case 'ico':
+ return 'image/x-icon';
+ case 'jpeg':
+ case 'jpg':
+ return 'image/jpeg';
+ case 'svg':
+ return 'image/svg+xml';
+ case 'webp':
+ return 'image/webp';
+ default:
+ return 'image/png';
+ }
+};
+
+const base64ToBlob = (content: string, mime: string) => {
+ const binary = window.atob(content.replace(/\n/g, ''));
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return new Blob([bytes], { type: mime });
+};
+
+const fileToBase64 = async (file: File) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onerror = () => {
+ reject(reader.error ?? new Error('Failed to read image.'));
+ };
+ reader.onload = () => {
+ const result = reader.result;
+ if (typeof result !== 'string') {
+ reject(new Error('Failed to encode image.'));
+ return;
+ }
+ resolve(result.slice(result.indexOf(',') + 1));
+ };
+ reader.readAsDataURL(file);
+ });
+
+const hasBytes = (bytes: Uint8Array, expected: number[]) => expected.every((value, index) => bytes[index] === value);
+
+const inferExtensionFromBytes = (bytes: Uint8Array) => {
+ if (bytes.length >= 8 && hasBytes(bytes, [137, 80, 78, 71, 13, 10, 26, 10])) return 'png';
+ if (bytes.length >= 3 && hasBytes(bytes, [0xff, 0xd8, 0xff])) return 'jpg';
+ const header = new TextDecoder().decode(bytes.subarray(0, 12));
+ if (header.startsWith('GIF87a') || header.startsWith('GIF89a')) return 'gif';
+ if (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP') return 'webp';
+ if (header.startsWith('