diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore deleted file mode 100644 index 9de0f16..0000000 --- a/.codegraph/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55ac992..a308245 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -42,7 +42,7 @@ jobs: run: cargo install cargo-make - name: Run tests - run: pnpm ts:check && pnpm lint && cd ./crates && cargo make ci + run: pnpm type-check:full && pnpm lint && cd ./crates && cargo make ci build-macos: name: Build macOS @@ -66,7 +66,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -106,7 +106,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -143,7 +143,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -219,4 +219,3 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.gitignore b/.gitignore index bd9727c..b96f230 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ editor-settings.json Markdown-Editor.app markdown-editor-macos.zip markdown-editor-windows.zip + +.zvec-grep diff --git a/.husky/pre-commit b/.husky/pre-commit index a2483cb..4669f93 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm ts:check && pnpm lint && cd ./crates && cargo make commit +pnpm type-check && pnpm lint && cd ./crates && cargo make commit diff --git a/client/package.json b/client/package.json index bba7b93..1a74edb 100644 --- a/client/package.json +++ b/client/package.json @@ -6,56 +6,35 @@ "dev": "cross-env SERVER_PORT=3024 rsbuild dev --port 4000", "build": "cross-env SERVER_PORT=7024 rsbuild build", "preview": "rsbuild preview", - "ts:check": "tsc --noEmit" + "type-check": "tsc --noEmit" }, "dependencies": { + "@markdown-editor/core": "workspace:*", + "@markdown-editor/github-workspace": "workspace:*", "@codemirror/lang-markdown": "0.19.6", - "@codemirror/language": "^6.11.3", "@codemirror/language-data": "0.19.2", - "@emotion/css": "11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@milkdown/crepe": "7.21.2", - "@milkdown/kit": "7.21.2", - "@milkdown/react": "7.21.2", - "@milkdown/utils": "7.21.2", "@mui/icons-material": "^7.2.0", + "@octokit/rest": "^22.0.1", "@reduxjs/toolkit": "^1.7.1", - "@uiw/codemirror-theme-github": "^4.24.2", "@uiw/react-codemirror": "4.5.3", "@uiw/react-split": "^5.9.3", - "clipboard": "^2.0.11", - "mdast-util-from-markdown": "^2.0.2", - "mdast-util-to-markdown": "^2.1.2", - "mermaid": "^11.11.0", - "micromark-extension-highlight-mark": "^1.2.0", - "micromark-util-types": "^2.0.2", "primeicons": "^7.0.0", "primereact": "^10.9.6", - "prosemirror-inputrules": "1.5.1", "react": "19.1.0", "react-complex-tree": "^2.6.0", "react-dom": "19.1.0", "react-redux": "^9.2.0", - "react-router-dom": "^7.6.1", - "refractor": "4.9.0", - "remark-directive": "^4.0.0", - "remark-highlight-mark": "^1.4.0", - "unified": "^11.0.5", - "unist-util-add": "^1.2.0", - "unist-util-visit": "^5.0.0", - "uuid": "^13.0.0" + "react-router-dom": "^7.6.1" }, "devDependencies": { "@rsbuild/core": "^1.3.21", - "@rsbuild/plugin-less": "^1.2.4", "@rsbuild/plugin-react": "^1.3.1", "@rsbuild/plugin-sass": "^1.3.1", - "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", - "@types/react-redux": "7.1.34", - "typescript": "^5.8.3" + "typescript": "^7.0.2" } } diff --git a/client/src/App.scss b/client/src/App.scss index 042bf0d..372d126 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -97,3 +97,7 @@ body { .p-confirm-dialog-message { margin: 0; } + +.p-confirm-dialog-message { + margin-top: 0.5rem; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 1c48038..5768414 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,32 +11,38 @@ import { Menu } from './components/Menu/Menu'; import { Sidebar } from './components/Sidebar/Sidebar'; import { SplitBar } from './components/SplitBar'; import { APP_VERSION } from './constants'; +import { selectWorkspaceMode } from './redux-feature/githubWorkspaceSlice'; import { selectMenuCollapse } from './redux-feature/globalOptsSlice'; -import { useCheckServer, useWarnUnsavedOnUnload } from './utils/hooks/reduxHooks'; +import { useGitHubInstallationGuard } from './utils/hooks/githubInstallationHooks'; +import { useCheckServer, useWarnBeforeUnload } from './utils/hooks/reduxHooks'; +import { useGitHubWorkspaceSync } from './utils/hooks/workspaceHooks'; import './App.scss'; export const App: FC = () => { - useWarnUnsavedOnUnload(); + useWarnBeforeUnload(); - const { isLoading, isError, isSuccess, data: serverCheckRes } = useCheckServer(); + const workspaceMode = useSelector(selectWorkspaceMode); + useGitHubInstallationGuard(); + useGitHubWorkspaceSync(); + const { isLoading, isError, isSuccess, data: serverCheckRes } = useCheckServer(workspaceMode === 'local'); const menuCollapse = useSelector(selectMenuCollapse); const navigate = useNavigate(); - const showMenu = !isError && !menuCollapse; + const showMenu = (workspaceMode === 'github' || !isError) && !menuCollapse; useEffect(() => { - if (isError) { + if (workspaceMode === 'local' && isError) { void navigate('/internal/guide'); return; } - if (!isLoading && serverCheckRes?.version !== APP_VERSION) { + if (workspaceMode === 'local' && !isLoading && serverCheckRes?.version !== APP_VERSION) { void navigate('/internal/version-mismatch'); } - }, [isError, isSuccess, serverCheckRes]); + }, [isError, isSuccess, serverCheckRes, workspaceMode]); - if (isLoading) { + if (workspaceMode === 'local' && isLoading) { return (
diff --git a/client/src/assets.d.ts b/client/src/assets.d.ts new file mode 100644 index 0000000..875203d --- /dev/null +++ b/client/src/assets.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.scss'; diff --git a/client/src/components/DocSearch/DocSearch.scss b/client/src/components/DocSearch/DocSearch.scss index 05bea49..6e8cca2 100644 --- a/client/src/components/DocSearch/DocSearch.scss +++ b/client/src/components/DocSearch/DocSearch.scss @@ -16,11 +16,16 @@ white-space: nowrap; transition: $transition; - &:hover { + &:hover:not(:disabled) { color: $headerTextColor; border-color: $hoverBorderColor; } + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + .search-trigger-icon { font-size: 0.85rem; } @@ -47,11 +52,11 @@ .doc-search-dialog { width: 600px; background-color: $backgroundColor; - .search-panel { display: flex; flex-direction: column; gap: 0.75rem; + padding-top: 1rem; } .search-mode-toggle.p-selectbutton { @@ -136,6 +141,30 @@ } } } + + .search-submit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 38px; + padding: 0.35rem 0.75rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + background-color: $hoverBgcColor; + color: $headerTextColor; + cursor: pointer; + transition: $transition; + + &:hover:not(:disabled) { + border-color: $hoverBorderColor; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } } .search-filters-section { @@ -195,6 +224,57 @@ padding: 2rem 0; @include flex-center(); } + + .search-status { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding: 0.65rem 0.75rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + color: $shallowTextColor; + font-size: 0.82rem; + } + + .search-error { + border-color: rgba(220, 53, 69, 0.45); + color: #dc3545; + } + + .search-warning { + border-color: rgba(255, 193, 7, 0.45); + } + + .search-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + color: $shallowTextColor; + font-size: 0.85rem; + } + + .load-more-btn { + display: block; + margin: 0.75rem auto 0.25rem; + padding: 0.35rem 0.9rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + background: transparent; + color: $headerTextColor; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: $hoverBgcColor; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } } .result-item { diff --git a/client/src/components/DocSearch/DocSearch.tsx b/client/src/components/DocSearch/DocSearch.tsx index 5016d5d..b9b595b 100644 --- a/client/src/components/DocSearch/DocSearch.tsx +++ b/client/src/components/DocSearch/DocSearch.tsx @@ -3,35 +3,36 @@ import { IconField } from 'primereact/iconfield'; import { InputIcon } from 'primereact/inputicon'; import { InputText } from 'primereact/inputtext'; import { SelectButton } from 'primereact/selectbutton'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { SearchResults } from './SearchResults'; -import { - FileContentMatches, - FileNameMatch, - useLazySearchContentQuery, - useLazySearchFilesQuery, -} from '@/redux-api/searchApi'; -import { useDebounce } from '@/utils/hooks/tools'; +import type { FileContentMatches, FileNameMatch } from '@/redux-api/searchApi'; + +import { selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; +import { useGitHubSearchAvailability, useWorkspaceSearch, WorkspaceSearchMode } from '@/utils/hooks/searchHooks'; import { normalizePath } from '@/utils/utils'; import './DocSearch.scss'; -const DEBOUNCE_MS = 350; - -type SearchMode = 'content' | 'files'; - const MODE_OPTIONS = [ { label: 'Files', value: 'files' }, { label: 'Content', value: 'content' }, ]; -export const DocSearch: FC = () => { +const GITHUB_SEARCH_RESULT_LIMIT = 1000; + +interface DocSearchProps { + disabled?: boolean; +} + +export const DocSearch: FC = ({ disabled = false }) => { const [visible, setVisible] = useState(false); - const [searchMode, setSearchMode] = useState('files'); + const [searchMode, setSearchMode] = useState('files'); const [query, setQuery] = useState(''); + const [submittedQuery, setSubmittedQuery] = useState(''); const [matchCase, setMatchCase] = useState(false); const [includeFiles, setIncludeFiles] = useState(''); const [excludeFiles, setExcludeFiles] = useState(''); @@ -40,94 +41,137 @@ export const DocSearch: FC = () => { const [fileResults, setFileResults] = useState([]); const [contentResults, setContentResults] = useState([]); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); + const [hasSearched, setHasSearched] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [documentOnlyContent, setDocumentOnlyContent] = useState(false); + const [searchError, setSearchError] = useState(); + const [incompleteResults, setIncompleteResults] = useState(false); + const [totalCount, setTotalCount] = useState(); + const [nextPage, setNextPage] = useState(); + const [rateLimitReset, setRateLimitReset] = useState(); - const [searchFiles] = useLazySearchFilesQuery(); - const [searchContent] = useLazySearchContentQuery(); + const workspace = useSelector(selectGithubWorkspace); + const searchWorkspace = useWorkspaceSearch(); + const searchAvailability = useGitHubSearchAvailability(); + const requestSequenceRef = useRef(0); const navigate = useNavigate(); - const performSearch = useCallback( - (q: string, mode: SearchMode, mc: boolean, inc: string, exc: string) => { - const trimmed = q.trim(); - if (trimmed === '') { - if (mode === 'files') setFileResults([]); - else setContentResults([]); - return; - } - if (mode === 'files') { - searchFiles(trimmed) - .unwrap() - .then((data) => { - setFileResults(data ?? []); - }) - .catch(() => { - setFileResults([]); - }); - } else { - searchContent({ - q: trimmed, - caseSensitive: mc, - includeFiles: inc.trim() || undefined, - excludeFiles: exc.trim() || undefined, - }) - .unwrap() - .then((data) => { - setContentResults(data ?? []); - }) - .catch(() => { - setContentResults([]); - }) - .finally(() => { - setCollapsedFiles(new Set()); - }); - } - }, - [searchFiles, searchContent, setCollapsedFiles], - ); - - const debouncedSearch = useDebounce(performSearch, DEBOUNCE_MS, [], false); + const resetResults = useCallback(() => { + requestSequenceRef.current += 1; + setFileResults([]); + setContentResults([]); + setCollapsedFiles(new Set()); + setSubmittedQuery(''); + setHasSearched(false); + setIsSearching(false); + setDocumentOnlyContent(false); + setSearchError(undefined); + setIncompleteResults(false); + setTotalCount(undefined); + setNextPage(undefined); + }, []); const handleQueryChange = useCallback( (value: string) => { setQuery(value); - debouncedSearch(value, searchMode, matchCase, includeFiles, excludeFiles); + resetResults(); }, - [searchMode, matchCase, includeFiles, excludeFiles, debouncedSearch], + [resetResults], ); const handleModeChange = useCallback( - (mode: SearchMode) => { + (mode: WorkspaceSearchMode) => { setSearchMode(mode); - performSearch(query, mode, matchCase, includeFiles, excludeFiles); + resetResults(); }, - [query, matchCase, includeFiles, excludeFiles, performSearch], + [resetResults], ); const handleMatchCaseToggle = useCallback(() => { const next = !matchCase; setMatchCase(next); - if (searchMode === 'content') { - performSearch(query, searchMode, next, includeFiles, excludeFiles); - } - }, [matchCase, searchMode, query, includeFiles, excludeFiles, performSearch]); + resetResults(); + }, [matchCase, resetResults]); const handleIncludeChange = useCallback( (value: string) => { setIncludeFiles(value); - if (searchMode === 'content') { - debouncedSearch(query, searchMode, matchCase, value, excludeFiles); - } + resetResults(); }, - [searchMode, query, matchCase, excludeFiles, debouncedSearch], + [resetResults], ); const handleExcludeChange = useCallback( (value: string) => { setExcludeFiles(value); - if (searchMode === 'content') { - debouncedSearch(query, searchMode, matchCase, includeFiles, value); + resetResults(); + }, + [resetResults], + ); + + const performSearch = useCallback( + async (page = 1) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery || isSearching || !searchAvailability.supported) return; + if (rateLimitReset && rateLimitReset > Date.now()) return; + + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setIsSearching(true); + setSearchError(undefined); + try { + const response = await searchWorkspace({ + query: trimmedQuery, + mode: searchMode, + caseSensitive: matchCase, + includeFiles: includeFiles.trim() || undefined, + excludeFiles: excludeFiles.trim() || undefined, + page, + }); + if (requestSequenceRef.current !== requestSequence) return; + + setSubmittedQuery(trimmedQuery); + setHasSearched(true); + setDocumentOnlyContent(response.documentOnlyContent); + setIncompleteResults(response.incompleteResults); + setTotalCount(response.totalCount); + setNextPage(response.nextPage); + setCollapsedFiles(new Set()); + setContentResults(response.contentResults); + setFileResults((previous) => { + if (page === 1 || !response.documentOnlyContent) return response.fileResults; + const merged = new Map(previous.map((item) => [normalizePath(item.path), item])); + response.fileResults.forEach((item) => merged.set(normalizePath(item.path), item)); + return [...merged.values()]; + }); + } catch (error) { + if (requestSequenceRef.current !== requestSequence) return; + const githubError = error as { message?: string; rateLimitReset?: number }; + if (githubError.rateLimitReset && githubError.rateLimitReset > Date.now()) { + setRateLimitReset(githubError.rateLimitReset); + setSearchError( + `GitHub search rate limit reached. Try again after ${new Date( + githubError.rateLimitReset, + ).toLocaleTimeString()}.`, + ); + } else { + setSearchError(githubError.message ?? 'Search failed.'); + } + } finally { + if (requestSequenceRef.current === requestSequence) setIsSearching(false); } }, - [searchMode, query, matchCase, includeFiles, debouncedSearch], + [ + excludeFiles, + includeFiles, + isSearching, + matchCase, + query, + rateLimitReset, + searchAvailability.supported, + searchMode, + searchWorkspace, + ], ); const navigateToDoc = useCallback( @@ -162,13 +206,18 @@ export const DocSearch: FC = () => { }, []); const openSearch = useCallback(() => { + if (disabled) return; setVisible(true); - }, []); + }, [disabled]); const onHide = useCallback(() => { setVisible(false); }, []); + useEffect(() => { + if (disabled) setVisible(false); + }, [disabled]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -186,12 +235,52 @@ export const DocSearch: FC = () => { }; }, [openSearch, onHide, visible]); - const currentQuery = query.trim(); + useEffect(() => { + resetResults(); + setRateLimitReset(undefined); + }, [resetResults, workspace.config.branch, workspace.config.owner, workspace.config.repo, workspace.mode]); + + useEffect(() => { + if (!rateLimitReset) return; + const remaining = rateLimitReset - Date.now(); + if (remaining <= 0) { + setRateLimitReset(undefined); + return; + } + const timer = window.setTimeout(() => { + setRateLimitReset(undefined); + setSearchError(undefined); + }, remaining); + return () => { + window.clearTimeout(timer); + }; + }, [rateLimitReset]); + + const currentQuery = submittedQuery.trim(); const isFileMode = searchMode === 'files'; + const isGitHubMode = workspace.mode === 'github'; + const isRateLimited = Boolean(rateLimitReset && rateLimitReset > Date.now()); + const displayedSearchError = + searchError ?? + (isRateLimited && rateLimitReset + ? `GitHub search rate limit reached. Try again after ${new Date(rateLimitReset).toLocaleTimeString()}.` + : undefined); + const canSubmit = Boolean(query.trim() && !isSearching && searchAvailability.supported && !isRateLimited); + const resultWarning = [ + incompleteResults ? 'GitHub returned incomplete search results.' : '', + totalCount !== undefined && totalCount > GITHUB_SEARCH_RESULT_LIMIT + ? `GitHub found ${totalCount.toLocaleString()} results, but only the first 1,000 are available. Refine the query to see more.` + : '', + ] + .filter(Boolean) + .join(' '); return ( -
- {!isFileMode && (
- - {!isFileMode && ( + {!isGitHubMode && ( )} +
)}
@@ -278,11 +381,20 @@ export const DocSearch: FC = () => { void performSearch(nextPage)} onNavigate={navigateToDoc} onToggleCollapse={toggleFileCollapse} onCollapseAll={collapseAll} diff --git a/client/src/components/DocSearch/SearchResults.tsx b/client/src/components/DocSearch/SearchResults.tsx index 603aa21..7176133 100644 --- a/client/src/components/DocSearch/SearchResults.tsx +++ b/client/src/components/DocSearch/SearchResults.tsx @@ -75,6 +75,15 @@ interface SearchResultsProps { fileResults: FileNameMatch[]; contentResults: FileContentMatches[]; collapsedFiles: Set; + documentOnlyContent: boolean; + hasSearched: boolean; + isSearching: boolean; + errorMessage?: string; + noticeMessage?: string; + noticeLoading?: boolean; + warningMessage?: string; + canLoadMore?: boolean; + onLoadMore?: () => void; onNavigate: (path: string[], searchContext?: { query: string; lineContent: string }) => void; onToggleCollapse: (filePath: string) => void; onCollapseAll: () => void; @@ -91,6 +100,15 @@ export const SearchResults: FC = ({ fileResults, contentResults, collapsedFiles, + documentOnlyContent, + hasSearched, + isSearching, + errorMessage, + noticeMessage, + noticeLoading, + warningMessage, + canLoadMore, + onLoadMore, onNavigate, onToggleCollapse, onCollapseAll, @@ -100,12 +118,24 @@ export const SearchResults: FC = ({ () => contentResults.length > 0 && contentResults.every((f) => collapsedFiles.has(normalizePath(f.path))), [contentResults, collapsedFiles], ); + const showFileResults = isFileMode || documentOnlyContent; + const emptyMessage = isFileMode ? 'No files found' : 'No matches found'; return (
- {isFileMode ? ( + {noticeMessage && ( +
+ {noticeLoading && } + {noticeMessage} +
+ )} + {errorMessage &&
{errorMessage}
} + {warningMessage &&
{warningMessage}
} + {showFileResults ? ( <> - {currentQuery !== '' && fileResults.length === 0 &&
No files found
} + {hasSearched && !isSearching && currentQuery !== '' && fileResults.length === 0 && ( +
{emptyMessage}
+ )} {fileResults.map((item) => (
= ({
))} + {isSearching && ( +
+ Searching… +
+ )} + {canLoadMore && onLoadMore && ( + + )} ) : ( <> @@ -136,7 +176,9 @@ export const SearchResults: FC = ({
)} - {currentQuery !== '' && contentResults.length === 0 &&
No matches found
} + {hasSearched && !isSearching && currentQuery !== '' && contentResults.length === 0 && ( +
No matches found
+ )} {contentResults.map((file) => { const fileKey = normalizePath(file.path); const isCollapsed = collapsedFiles.has(fileKey); @@ -183,6 +225,11 @@ export const SearchResults: FC = ({ ); })} + {isSearching && ( +
+ Searching… +
+ )} )} diff --git a/client/src/components/Editor/DraftEditor.tsx b/client/src/components/Editor/DraftEditor.tsx index e803db3..c8ef603 100644 --- a/client/src/components/Editor/DraftEditor.tsx +++ b/client/src/components/Editor/DraftEditor.tsx @@ -1,22 +1,28 @@ -import { Ctx } from '@milkdown/kit/ctx'; -import { outline } from '@milkdown/utils'; +import { CrepeEditor, outline, type CrepeEditorRef, type Ctx } from '@markdown-editor/core'; import { ScrollPanel } from 'primereact/scrollpanel'; import React, { useEffect, useId, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { getImageUrl, uploadImage } from './configs/uploadConfig'; import { getGuideDoc, getLocalModeGuideDoc } from './internalDocs/guide'; import { getVersionMismatchDoc } from './internalDocs/versionMismatch'; -import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; import { EditorRef } from './type'; import { ONLINE_MODE } from '@/constants'; -import { updateCurDoc, selectCurTabs, DocType, selectCurDoc, clearCurDoc } from '@/redux-feature/curDocSlice'; +import { + updateCurDoc, + updateHeadings, + updateScrolling, + selectCurTabs, + DocType, + selectCurDoc, + clearCurDoc, +} from '@/redux-feature/curDocSlice'; import { selectNarrowMode, selectReadonly, selectTheme, updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import Toast from '@/utils/Toast'; import { normalizeEOL } from '@/utils/utils'; -import '@milkdown/crepe/theme/common/style.css'; -import '@milkdown/crepe/theme/frame.css'; import './Editor.scss'; const getDoc = (docId: string, type: DocType) => { @@ -49,6 +55,10 @@ export interface DraftEditorProps { ref: React.RefObject; } +function tabScrollTop(tabs: { ident: string; scroll: number }[], ident: string | undefined) { + return tabs.find((tab) => tab.ident === ident)?.scroll ?? 0; +} + export const DraftEditor: React.FC = ({ ref: editorWrappedRef, type }) => { const uniqueId = useId(); @@ -91,7 +101,7 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, }; }, []); - // when switching the doc (or same doc refetched) + // when switching the doc (or same doc re-fetched) useEffect(() => { if (doc?.id === contentIdent) return; @@ -121,6 +131,8 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, // only for draft doc, since internal doc should not be edited and saved const onUpdated = (ctx: Ctx, markdown: string) => { + if (type === 'internal') return; + const isDirty = normalizeEOL(markdown) !== normalizeEOL(doc?.content); const headings = outline()(ctx); @@ -132,6 +144,7 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, contentIdent: docId, title: doc?.title, headings, + syncTab: false, type, }), ); @@ -151,6 +164,16 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, defaultValue={storedContent} isDarkMode={theme === 'dark'} readonly={readonly} + initialScrollTop={tabScrollTop(curTabs, contentIdent)} + getScrollContainer={() => document.querySelector('.editor-box .p-scrollpanel-content')} + onHeadingsChange={(headings) => dispatch(updateHeadings(headings))} + onScroll={(scrollTop) => dispatch(updateScrolling({ scrollTop }))} + onAnchorChange={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onToAnchor={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onBlurChange={(isBlurred) => dispatch(updateGlobalOpts({ keys: ['isEditorBlur'], values: [isBlurred] }))} + onToast={(message) => Toast(message)} + uploadImage={uploadImage} + getImageUrl={getImageUrl} onUpdated={onUpdated} /> diff --git a/client/src/components/Editor/Editor.scss b/client/src/components/Editor/Editor.scss index 29f449b..59173b1 100644 --- a/client/src/components/Editor/Editor.scss +++ b/client/src/components/Editor/Editor.scss @@ -1,8 +1,5 @@ -@use '../../utils/utils.scss' as *; -@use '../DocMirror/DocMirror.scss' as DocMirror; -@use './plugins/plugin-iframe/index.scss'; -@use './plugins/plugin-heading/heading.scss'; -@use './plugins/plugin-container'; +@use '../../utils/utils.scss' as *; +@use '../DocMirror/DocMirror.scss' as DocMirror; .editor-box { overflow: hidden; diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 401cb90..ecace2b 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -1,25 +1,36 @@ -import { editorViewCtx } from '@milkdown/kit/core'; -import { Ctx } from '@milkdown/kit/ctx'; -import { outline } from '@milkdown/utils'; +import { + CrepeEditor, + editorViewCtx, + outline, + searchAndHighlight, + type CrepeEditorRef, + type Ctx, +} from '@markdown-editor/core'; import { ScrollPanel } from 'primereact/scrollpanel'; import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams, useLocation } from 'react-router-dom'; -import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; -import { searchAndHighlight } from './mountedAddons'; +import { getImageUrl, uploadImage } from './configs/uploadConfig'; import { EditorRef } from './type'; -import { useGetDocQuery } from '@/redux-api/docs'; import { useGetSettingsQuery } from '@/redux-api/settings'; -import { updateCurDoc, selectCurDoc, selectCurTabs, clearCurDoc } from '@/redux-feature/curDocSlice'; +import { + updateCurDoc, + updateHeadings, + updateScrolling, + selectCurDoc, + selectCurTabs, + clearCurDoc, +} from '@/redux-feature/curDocSlice'; import { clearDraft, selectDraft, setDraft } from '@/redux-feature/draftsSlice'; -import { selectNarrowMode, selectReadonly, selectTheme } from '@/redux-feature/globalOptsSlice'; +import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; +import { selectNarrowMode, selectReadonly, selectTheme, updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import { useDeleteTab } from '@/utils/hooks/reduxHooks'; +import { useWorkspaceDocQuery } from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; import { getDraftKey, normalizePath, normalizeEOL } from '@/utils/utils'; -import '@milkdown/crepe/theme/common/style.css'; -import '@milkdown/crepe/theme/frame.css'; import './Editor.scss'; const SEARCH_HIGHLIGHT_DELAY_SAME_DOC = 50; @@ -43,17 +54,21 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } // useGetDocQuery will be cached (within a limited time) according to different contentPath // with auto refetch when the doc is updated - const { data: fetchedDoc = getDefaultDoc(), isSuccess, error } = useGetDocQuery(curDocPath); - const { data: settings } = useGetSettingsQuery(); + const githubWorkspace = useSelector(selectGithubWorkspace); + const { data: fetchedDoc = getDefaultDoc(), isSuccess, error, isMissing } = useWorkspaceDocQuery(curDocPath); + const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const { content: storedContent, contentIdent: storedContentPath } = useSelector(selectCurDoc); - const draftKey = getDraftKey(settings?.docRootPath, curDocPath); + const workspaceKey = + githubWorkspace.mode === 'github' ? getGitHubWorkspaceKey(githubWorkspace.config) : settings?.docRootPath; + const draftKey = getDraftKey(workspaceKey, curDocPath); const draft = useSelector(selectDraft(draftKey)); const theme = useSelector(selectTheme); const readonly = useSelector(selectReadonly); const narrowMode = useSelector(selectNarrowMode); const dispatch = useDispatch(); + const deleteTab = useDeleteTab(); const crepeEditorRef = useRef(null); @@ -123,18 +138,26 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } }, []); useEffect(() => { - if (error) { - void navigate('/'); - return Toast.error((error as unknown as Error).message ?? 'Failed to fetch doc'); + if (!error) return; + if (isMissing) { + // Removing the missing tab also navigates to the last remaining tab (or purePage + // when no tabs remain), using the same flow as a user-closing or deleted document. + void deleteTab([curDocPath], { force: true }); + } else { + // Do not delete a valid tab for transient loading, authentication, or network errors. + void navigate('/purePage'); } - }, [error]); + Toast.error((error as unknown as Error).message ?? 'Failed to fetch doc'); + }, [curDocPath, error, isMissing, navigate]); - // when switching the doc (or same doc refetched) + // when switching the doc (or same doc re-fetched) useEffect(() => { if (!isSuccess) { return; } - if (fetchedDoc?.filePath === storedContentPath) return; + const sameDocument = fetchedDoc?.filePath === storedContentPath; + const sameContent = normalizeEOL(fetchedDoc?.content ?? '') === normalizeEOL(storedContent); + if (sameDocument && sameContent) return; const tab = curTabs.find(({ ident }) => ident === curDocPath); const ctx = crepeEditorRef.current?.get()?.ctx; @@ -153,7 +176,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } ); crepeEditorRef.current?.reRender(); - }, [fetchedDoc]); + }, [fetchedDoc?.content, fetchedDoc?.filePath]); const onUpdated = (ctx: Ctx, markdown: string) => { const isDirty = normalizeEOL(markdown) !== normalizeEOL(fetchedDoc?.content ?? ''); @@ -165,6 +188,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } isDirty, contentIdent: curDocPath, headings, + syncTab: false, type: 'workspace', }), ); @@ -184,6 +208,16 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } defaultValue={storedContent} isDarkMode={theme === 'dark'} readonly={readonly} + initialScrollTop={curTabs.find((tab) => tab.ident === storedContentPath)?.scroll ?? 0} + getScrollContainer={() => document.querySelector('.editor-box .p-scrollpanel-content')} + onHeadingsChange={(headings) => dispatch(updateHeadings(headings))} + onScroll={(scrollTop) => dispatch(updateScrolling({ scrollTop }))} + onAnchorChange={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onToAnchor={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onBlurChange={(isBlurred) => dispatch(updateGlobalOpts({ keys: ['isEditorBlur'], values: [isBlurred] }))} + onToast={(message) => Toast(message)} + uploadImage={uploadImage} + getImageUrl={getImageUrl} onUpdated={onUpdated} onMounted={onMounted} /> diff --git a/client/src/components/Editor/MilkdownEditor.tsx b/client/src/components/Editor/MilkdownEditor.tsx deleted file mode 100644 index b9ccc95..0000000 --- a/client/src/components/Editor/MilkdownEditor.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Editor, editorViewCtx, editorViewOptionsCtx, parserCtx } from '@milkdown/kit/core'; -import { Ctx } from '@milkdown/kit/ctx'; -import { listenerCtx } from '@milkdown/kit/plugin/listener'; -import { Slice } from '@milkdown/kit/prose/model'; -import { Milkdown, useEditor } from '@milkdown/react'; -import { outline } from '@milkdown/utils'; -import { FC, useEffect, useImperativeHandle, useReducer, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { getCrepe } from './crepe'; -import { removeEvents, scrollHandler, blurHandler, anchorHandler, syncMirror } from './mountedAddons'; -import { headingConfig } from './plugins/plugin-heading'; - -import { selectCurDoc, updateHeadings } from '@/redux-feature/curDocSlice'; -import { updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; -import { scrollToEditorAnchor } from '@/utils/hooks/docHooks'; -import { updateLocationHash } from '@/utils/utils'; - -export interface CrepeEditorRef { - update: (newContent: string) => void; - get: () => Editor | undefined; - /** used to trigger reser the editor */ - reRender: () => void; -} - -export interface CrepeEditorProps { - /** default as "" */ - defaultValue?: string; - /** default as false */ - isDarkMode?: boolean; - /** default as false */ - readonly?: boolean; - onMounted?: (ctx: Ctx) => void; - onUpdated?: (ctx: Ctx, markdown: string) => void; - onToAnchor?: (id: string) => void; - - ref: React.RefObject; -} - -export const CrepeEditor: FC = ({ - defaultValue = '', - isDarkMode = false, - readonly = false, - onMounted, - onUpdated, - onToAnchor, - ref: editorWrappedRef, -}) => { - const [reRenderMarker, reRender] = useReducer((x) => x + 1, 0); - - const dispatch = useDispatch(); - const { scrollTop } = useSelector(selectCurDoc); - - // This is used to avoid the closure issue - // when hook functions from props are updated but editor is not reset - const hookRefs = useRef<{ - onUpdated: CrepeEditorProps['onUpdated']; - onMounted: CrepeEditorProps['onMounted']; - onToAnchor: CrepeEditorProps['onToAnchor']; - }>({ - onUpdated: (...args) => { - onUpdated?.(...args); - }, - onMounted: (...args) => { - onMounted?.(...args); - }, - onToAnchor: (...args) => { - onToAnchor?.(...args); - }, - }); - - useEffect(() => { - hookRefs.current.onUpdated = onUpdated; - hookRefs.current.onMounted = onMounted; - hookRefs.current.onToAnchor = onToAnchor; - }, [onUpdated, onMounted, onToAnchor]); - - const { get } = useEditor( - (root) => { - const crepe = getCrepe({ - root, - defaultValue, - isDarkMode, - }); - - crepe.editor.config((ctx) => { - ctx - .get(listenerCtx) - .mounted(() => { - dispatch(updateHeadings(outline()(ctx))); - - removeEvents(); - - scrollHandler(scrollTop, dispatch); - - blurHandler(dispatch); - - // has higher priority than the scrollHandler - anchorHandler(dispatch); - - syncMirror(readonly); - - hookRefs.current.onMounted?.(ctx); - }) - .markdownUpdated((_, markdown) => { - const headings = outline()(ctx); - dispatch(updateHeadings(headings)); - - hookRefs.current.onUpdated?.(ctx, markdown); - }); - - // edit mode - ctx.set(editorViewOptionsCtx, { - editable: () => !readonly, - }); - - ctx.set(headingConfig.key, { - toAnchor: (id: string) => { - updateLocationHash(id); - scrollToEditorAnchor(id); - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [id] })); - - hookRefs.current.onToAnchor?.(id); - }, - }); - }); - - return crepe; - }, - [isDarkMode, readonly, reRenderMarker], - ); - - // for update the editor using a wrapped ref - useImperativeHandle( - editorWrappedRef, - () => ({ - get, - reRender, - update: (markdown: string) => { - const editor = get(); - if (!editor) return; - - editor.action((ctx) => { - const view = ctx.get(editorViewCtx); - const parser = ctx.get(parserCtx); - const doc = parser(markdown); - if (!doc) return; - - const state = view.state; - view.dispatch(state.tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0))); - }); - }, - }), - [get, reRender], - ); - - return ; -}; diff --git a/client/src/components/Editor/components/TooltipInput/index.tsx b/client/src/components/Editor/components/TooltipInput/index.tsx deleted file mode 100644 index 0afa8e4..0000000 --- a/client/src/components/Editor/components/TooltipInput/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { InputText } from 'primereact/inputtext'; -import { Tooltip, TooltipProps } from 'primereact/tooltip'; -import { FC } from 'react'; - -import { Icon, IconProps } from '@/components/Icon/Icon'; - -import './index.scss'; - -export interface TooltipInputProps { - tooltipOptions: TooltipProps; - iconOptions: IconProps; - value: string; - placeholder?: string; - onChange: (value: string) => void; - onConfirm: () => void; -} - -export const TooltipInput: FC = ({ - tooltipOptions, - iconOptions, - value, - placeholder, - onChange, - onConfirm, -}) => { - return ( - - { - onChange(e.target.value); - }} - /> - { - onConfirm(); - }} - showToolTip={false} - {...iconOptions} - /> - - ); -}; diff --git a/client/src/components/Editor/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 21d9696..b9d081d 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -1,12 +1,25 @@ -import { inlineImageConfig } from '@milkdown/kit/component/image-inline'; -import { uploadConfig, Uploader } from '@milkdown/kit/plugin/upload'; +import { GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, SERVER_BASE_URL, WORKSPACE_MODE_STORAGE_KEY } from '@/constants'; +import { emptyConfig, type GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import { clearGitHubImageUrlCache, resolveGitHubImageUrl, uploadGitHubImage } from '@/utils/githubImages'; -import type { Ctx } from '@milkdown/kit/ctx'; -import type { Node } from '@milkdown/kit/prose/model'; +const getWorkspaceMode = () => window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY); -import { SERVER_BASE_URL } from '@/constants'; +const getGitHubConfig = (): GitHubWorkspaceConfig => { + try { + return { + ...emptyConfig, + ...(JSON.parse( + window.localStorage.getItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY) ?? '{}', + ) as Partial), + }; + } catch { + return emptyConfig; + } +}; -export function getImageUrl(url: string) { +export function getImageUrl(url: string): Promise | string { + if (getWorkspaceMode() === 'github') return resolveGitHubImageUrl(getGitHubConfig(), url); + clearGitHubImageUrlCache(); if (url.startsWith('/')) { return `${SERVER_BASE_URL}/imgs${url}`; } @@ -14,6 +27,7 @@ export function getImageUrl(url: string) { } export async function uploadImage(file: File) { + if (getWorkspaceMode() === 'github') return uploadGitHubImage(getGitHubConfig(), file); const formData = new FormData(); formData.append('file', file); const res = await fetch(`${SERVER_BASE_URL}/imgs/upload`, { @@ -23,51 +37,3 @@ export async function uploadImage(file: File) { const json = await res.json(); return json.data as string; } - -const uploader: Uploader = async (files, schema) => { - const images: File[] = []; - - for (let i = 0; i < files.length; i++) { - const file = files.item(i); - if (!file) { - continue; - } - - // we only handle image here. - if (!file.type.includes('image')) { - continue; - } - - images.push(file); - } - - const nodes: Node[] = await Promise.all( - images.map(async (image) => { - const src = await uploadImage(image); - const alt = image.name; - return schema.nodes.image.createAndFill({ - src, - alt, - })!; - }), - ); - - return nodes; -}; - -export const configureImageUploader = (ctx: Ctx) => { - ctx.update(uploadConfig.key, (prev) => { - return { - ...prev, - uploader, - }; - }); - - ctx.update(inlineImageConfig.key, (prev) => { - return { - ...prev, - proxyDomURL: getImageUrl, - onUpload: uploadImage, - }; - }); -}; diff --git a/client/src/components/Editor/mountedAddons.tsx b/client/src/components/Editor/mountedAddons.tsx deleted file mode 100644 index f53010e..0000000 --- a/client/src/components/Editor/mountedAddons.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'; -import { useDispatch } from 'react-redux'; - -import type { EditorView } from '@milkdown/kit/prose/view'; - -import { updateScrolling } from '@/redux-feature/curDocSlice'; -import { updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; -import { getEditorScrollContainer, scrollToEditorAnchor } from '@/utils/hooks/docHooks'; -import { throttle } from '@/utils/utils'; - -let removers: (() => void)[] = []; -function pushRemover(r: () => void) { - removers.push(r); -} -/** - * remove all the events and unmounted doms of each addon - */ -export function removeEvents() { - removers.forEach((r) => { - r(); - }); - removers = []; -} - -/** - * handle scrolling - record the scrolling status of previous visited docs - * then record current docs scroll top globally - */ -export function scrollHandler(prevScroll: number, dispatch: ReturnType) { - const milkdownDom = getEditorScrollContainer(); - if (!milkdownDom) return; - - // get the previous scroll top - milkdownDom.scrollTop = prevScroll; - - const syncAnchor = () => { - const headings = document.querySelectorAll('.milkdown-heading'); - headings.forEach((ha) => { - const rect = ha.getBoundingClientRect(); - if (rect.top > 0 && rect.top < 150) { - // notify the outline to select the heading, but not set the anchor to location hash - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [ha.id] })); - } - }); - }; - - // wait for the outline - setTimeout(() => { - syncAnchor(); - }, 100); - - // bind the event after the first rendering caused by the above operation... - setTimeout(() => { - const eventFn = throttle(() => { - const currentScrollTop = milkdownDom.scrollTop; - - dispatch(updateScrolling({ scrollTop: currentScrollTop })); - - syncAnchor(); - }, 100); - - milkdownDom.addEventListener('scroll', eventFn); - - pushRemover(() => { - milkdownDom.removeEventListener('scroll', eventFn); - }); - }, 0); -} - -/** - * record blur status about if the mouse is on the milkdown or not - */ -export function blurHandler(dispatch: ReturnType) { - const milkdownDom = document.getElementsByClassName('milkdown')[0]; - - const enterFn = () => { - dispatch( - updateGlobalOpts({ - keys: ['isEditorBlur'], - values: [false], - }), - ); - }; - const leaveFn = () => { - dispatch( - updateGlobalOpts({ - keys: ['isEditorBlur'], - values: [true], - }), - ); - }; - - milkdownDom.addEventListener('mouseenter', enterFn); - milkdownDom.addEventListener('mouseleave', leaveFn); - - pushRemover(() => { - milkdownDom.removeEventListener('mouseenter', enterFn); - milkdownDom.removeEventListener('mouseleave', leaveFn); - }); -} - -/** - * handle anchor - */ -export function anchorHandler(dispatch: ReturnType) { - const locationHash = window.location.hash.slice(1); - let hashAnchor = ''; - if (locationHash) { - const heading = document.getElementById(decodeURI(locationHash)); - if (heading) { - hashAnchor = heading.id; - } - } - scrollToEditorAnchor(hashAnchor); - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [hashAnchor || ''] })); -} - -export function keywordsHandler(keywords: string[]) { - const domSet = new Set(); - // filter the repeated keyword doms - const strongDoms = [...document.getElementsByClassName('strong')].filter( - (dom) => !domSet.has(dom.innerHTML) && domSet.add(dom.innerHTML), - ); - - if (strongDoms && strongDoms.length !== 0) { - let idx = 0; - for (const strongDom of strongDoms) { - strongDom.setAttribute('id', keywords[idx].replace(/\s/g, '-').toLowerCase()); - idx++; - } - } -} - -/** - * sync the mirror anchor when double clicking a element in Editor - * but only works for readonly mode currently... - */ -export function syncMirror(readonly: boolean) { - if (!readonly) return; - - const editorDom = document.querySelector('.milkdown .editor'); - if (!editorDom) return; - - const blockDoms = editorDom.children; - - const blockLineNum = new Array(blockDoms.length).fill(0); - - // prerecord the line number of each big block (top children of the editor) - [...blockDoms].reduce((curTotalLine, blockDom, idx) => { - const lines = (blockDom as HTMLElement).innerText.split('\n'); - - // record the start line number - blockLineNum[idx] = curTotalLine; - - if (blockDom.querySelector('img') || blockDom.querySelector('iframe')) { - // special case handling for img and iframe block - curTotalLine--; - } - - if (blockDom.classList.contains('milkdown-code-block')) { - curTotalLine -= blockDom.querySelector('.cm-content')?.children.length ?? 0; - } - - return curTotalLine + lines.length + 1; - }, 0); - - [...blockDoms].forEach((blockDom, idx) => { - const dbClickEvent = (e: Event) => { - const mirrorDom = document.querySelector('.code-mirror-container .cm-content'); - const mirrorScroller = document.querySelector('.code-mirror-container .cm-scroller'); - if (!mirrorDom || !mirrorScroller) return; - - const lineDoms = mirrorDom.children; - if (lineDoms.length === 0 || !lineDoms[idx]) return; - - const oneLineHeight = Number(getComputedStyle(lineDoms[0]).height.replace('px', '')); - - const clickDom = e.target as HTMLElement; - - let lineNum = blockLineNum[idx]; - // when it is a paragraph and it is one of children of the blockDom - // make the position more accurate - if (clickDom !== blockDom && clickDom.tagName === 'P') { - const lines = (blockDom as HTMLElement).innerText.split('\n'); - - if (clickDom) { - const lineIdx = lines.findIndex((line) => line === clickDom.innerText); - - if (lineIdx) lineNum += lineIdx; - } - } - - mirrorScroller.scroll({ - // just make some offset - top: (lineNum - 3) * oneLineHeight, - behavior: 'smooth', - }); - }; - - blockDom.addEventListener('dblclick', dbClickEvent); - pushRemover(() => { - blockDom.removeEventListener('dblclick', dbClickEvent); - }); - }); -} - -const HIGHLIGHT_DURATION_MS = 3500; - -function applyHighlightAndScroll(view: EditorView, from: number, to: number) { - // Scroll the match into view, positioned at roughly 1/3 from the top - const scrollContainer = getEditorScrollContainer(); - if (scrollContainer) { - try { - const coords = view.coordsAtPos(from); - const containerRect = scrollContainer.getBoundingClientRect(); - const scrollTop = Number(scrollContainer.scrollTop); - scrollContainer.scrollTo({ - top: scrollTop + (coords.top - containerRect.top) - containerRect.height / 3, - behavior: 'instant', - }); - } catch { - /* ignore coordinate errors */ - } - } - - // Add a visual decoration highlight that works regardless of focus state. - // Native selection depends on focus, contenteditable, and browser quirks; - // decorations are rendered as styled DOM elements and always visible. - try { - view.setProps({ - decorations(state) { - try { - return DecorationSet.create(state.doc, [Decoration.inline(from, to, { class: 'search-match-highlight' })]); - } catch { - return DecorationSet.empty; - } - }, - }); - - setTimeout(() => { - try { - if (!view.dom.isConnected) return; - view.setProps({ decorations: () => DecorationSet.empty }); - } catch { - /* view may have been destroyed */ - } - }, HIGHLIGHT_DURATION_MS); - } catch { - /* setProps failed */ - } -} - -function stripMarkdownSyntax(text: string): string { - return text - .replace(/^#{1,6}\s+/, '') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/_(.+?)_/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/^\s*[-*+]\s+/, '') - .replace(/^\s*\d+\.\s+/, '') - .replace(/^\s*>\s+/, '') - .replace(/\[(.+?)\]\(.+?\)/g, '$1') - .trim(); -} - -/** - * Search for query text in the ProseMirror document and select the best match. - * Uses lineContent (raw markdown line) for disambiguation when multiple matches exist. - * Returns true if a match was found and selected. - */ -export function searchAndHighlight(view: EditorView, query: string, lineContent: string): boolean { - const { doc } = view.state; - const lowerQuery = query.toLowerCase(); - const strippedLine = stripMarkdownSyntax(lineContent).toLowerCase(); - - const matches: { from: number; to: number; blockText: string }[] = []; - - doc.descendants((node, pos) => { - if (!node.isTextblock) return; - - const text = node.textContent; - const lowerText = text.toLowerCase(); - let searchFrom = 0; - - while (searchFrom < lowerText.length) { - const idx = lowerText.indexOf(lowerQuery, searchFrom); - if (idx === -1) break; - - matches.push({ - from: pos + 1 + idx, - to: pos + 1 + idx + query.length, - blockText: lowerText, - }); - - searchFrom = idx + 1; - } - - return false; - }); - - if (matches.length === 0) return false; - - let bestMatch = matches[0]; - if (matches.length > 1 && strippedLine) { - for (const match of matches) { - if (strippedLine.includes(match.blockText) || match.blockText.includes(strippedLine)) { - bestMatch = match; - break; - } - } - } - - applyHighlightAndScroll(view, bestMatch.from, bestMatch.to); - - return true; -} diff --git a/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx b/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx deleted file mode 100644 index 850d085..0000000 --- a/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { ListBox } from 'primereact/listbox'; -import { FC, useEffect, useId, useMemo, useRef, useState } from 'react'; - -import { ContainerType } from './types'; - -import { TooltipInput } from '@/components/Editor/components/TooltipInput'; - -export interface ContainerTitleProps { - desc?: string; - containerType: ContainerType; - contentDom: HTMLElement; - getReadonly: () => boolean; - setAttrs?: (attrs: { containerType: ContainerType; desc: string }) => void; -} - -export const ContainerTitle: FC = ({ - desc = '', - containerType, - contentDom, - getReadonly, - setAttrs, -}) => { - const uid = useId(); - - const isDetails = containerType === ContainerType.DETAILS; - const title = desc || (isDetails ? 'Details' : containerType.toUpperCase()); - - const [showOperations, setShowOperations] = useState(false); - const [showSelectorMenu, setShowSelectorMenu] = useState(false); - const [showEditDesc, setEditDesc] = useState(false); - - const [descInput, setDescInput] = useState(desc); - - const iconRef = useRef(null); - const listContainerRef = useRef(null); - - const showSelectorIcon = useMemo(() => { - return (showOperations || showSelectorMenu) && !getReadonly(); - }, [showOperations, showSelectorMenu, getReadonly]); - const showEditDescIcon = useMemo(() => { - return (showOperations || showEditDesc) && !getReadonly(); - }, [showOperations, showEditDesc, getReadonly]); - - const onToggle = () => { - if (containerType !== ContainerType.DETAILS) { - return; - } - - iconRef.current?.classList.toggle('pi-angle-down'); - iconRef.current?.classList.toggle('pi-angle-right'); - contentDom.classList.toggle('container-content-hidden'); - }; - - const showSelectContainerType = (e: React.MouseEvent) => { - e.stopPropagation(); - setShowSelectorMenu(!showSelectorMenu); - }; - - const showDescEditor = (e: React.MouseEvent) => { - e.stopPropagation(); - setEditDesc(!showEditDesc); - }; - - const selectContainerType = (value: ContainerType) => { - setAttrs?.({ containerType: value, desc: descInput }); - }; - - const clickHandler = (e: MouseEvent) => { - const target = e.target as HTMLElement; - - if (listContainerRef.current?.contains?.(target)) return; - - setShowSelectorMenu(false); - }; - - useEffect(() => { - window.addEventListener('click', clickHandler); - return () => { - window.removeEventListener('click', clickHandler); - }; - }, []); - - const titleContent = ( -
- {title} - {showSelectorIcon && ( -
- - {showSelectorMenu && ( -
{ - e.stopPropagation(); - }} - > - { - selectContainerType(e.value); - }} - options={Object.keys(ContainerType)} - /> -
- )} -
- {showEditDescIcon && ( - - { - setDescInput(value); - }} - onConfirm={() => { - setAttrs?.({ containerType: containerType, desc: descInput }); - }} - /> - - )} -
- )} -
- ); - - return ( - { - setShowOperations(true); - }} - onMouseLeave={() => { - setShowOperations(false); - }} - > - {isDetails && } - {titleContent} - - ); -}; diff --git a/client/src/components/Editor/plugins/plugin-container/index.scss b/client/src/components/Editor/plugins/plugin-container/index.scss deleted file mode 100644 index a2a189e..0000000 --- a/client/src/components/Editor/plugins/plugin-container/index.scss +++ /dev/null @@ -1,78 +0,0 @@ -@use '@/utils/utils.scss' as *; -@use '@/components/Editor/components/TooltipInput'; - -$tipBackgroundColor: #646cff24; -$infoBackgroundColor: #8e96aa24; -$warningBackgroundColor: #eab30824; -$dangerBackgroundColor: #f43f5e24; -$detailsBackgroundColor: #8e96aa24; - -.milkdown-container-block { - margin: 16px 0; - padding: 16px 16px 8px; - border-radius: 8px; - &-title { - font-weight: 600; - line-height: 24px; - @include flex-center(); - justify-content: flex-start; - .pi { - margin-right: 3px; - cursor: pointer; - } - .title-container { - position: relative; - @include flex-center(); - justify-content: flex-start; - .operations { - margin-left: 5px; - @include flex-center(); - i { - font-size: 12px; - margin-right: 5px; - } - } - .list-container { - position: absolute; - z-index: 1000; - } - .p-listbox-list-wrapper, - .p-listbox-list, - .p-listbox-item-group { - border-radius: 5px; - padding: 0.5rem 0; - background-color: var(--crepe-color-surface); - } - .p-listbox { - // border: 1px solid $shadowColor; - box-shadow: var(--crepe-shadow-1); - } - .p-listbox-item { - padding: 0.5rem; - font-weight: bold; - } - } - } - .container-content-hidden { - display: none; - } - &[data-container-type="tip"] { - background-color: $tipBackgroundColor; - } - &[data-container-type="warning"] { - background-color: $warningBackgroundColor; - } - &[data-container-type="info"] { - background-color: $infoBackgroundColor; - } - &[data-container-type="danger"] { - background-color: $dangerBackgroundColor; - } - &[data-container-type="details"] { - background-color: $detailsBackgroundColor; - .milkdown-container-block-title { - margin-bottom: 8px; - cursor: pointer; - } - } -} \ No newline at end of file diff --git a/client/src/components/Editor/plugins/plugin-container/index.ts b/client/src/components/Editor/plugins/plugin-container/index.ts deleted file mode 100644 index 6d300e9..0000000 --- a/client/src/components/Editor/plugins/plugin-container/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { wrapIn } from '@milkdown/kit/prose/commands'; -import { $nodeSchema, $inputRule, $command, $view } from '@milkdown/utils'; -import { wrappingInputRule } from 'prosemirror-inputrules'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { ContainerTitle } from './ContainerTitle'; -import { ContainerType, ContainerNodeAttrs } from './types'; - -export * from './types'; - -export const containerSchema = $nodeSchema('container', () => { - return { - content: 'block+', - group: 'block', - defining: true, - attrs: { - containerType: { - default: ContainerType.TIP, - validate: (value) => Object.values(ContainerType).includes(value as ContainerType), - }, - desc: { - default: '', - validate: 'string', - }, - }, - parseDOM: [ - { - tag: 'div', - getAttrs: (dom) => ({ - containerType: dom.getAttribute('data-container-type') ?? ContainerType.TIP, - desc: dom.getAttribute('data-description') ?? '', - }), - }, - ], - parseMarkdown: { - match: (node) => { - return node.type === 'containerDirective' && Object.values(ContainerType).includes(node.name as ContainerType); - }, - runner: (state, node, type) => { - state - .openNode(type, { - containerType: node.name, - desc: (node.attributes as ContainerNodeAttrs).desc, - }) - .next(node.children) - .closeNode(); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'container', - runner: (state, node) => { - const attributes: Record = {}; - if (node.attrs.desc) { - attributes.desc = node.attrs.desc; - } - state - .openNode('containerDirective', undefined, { - name: node.attrs.containerType, - attributes, - }) - .next(node.content) - .closeNode(); - }, - }, - }; -}); - -const containerView = $view(containerSchema.node, () => { - return (initialNode, view, getPos) => { - const { containerType, desc } = initialNode.attrs; - const isDetails = containerType === ContainerType.DETAILS; - - const contentDom = document.createElement('p'); - contentDom.classList.add('milkdown-container-block-content'); - if (isDetails) { - contentDom.classList.add('container-content-hidden'); - } - - const dom = document.createElement('div'); - dom.classList.add('milkdown-container-block'); - dom.setAttribute('data-container-type', containerType); - dom.setAttribute('data-description', desc); - - const titleContainerDom = document.createElement('div'); - dom.appendChild(titleContainerDom); - dom.appendChild(contentDom); - - const titleRoot = createRoot(titleContainerDom); - - const setAttrs = (attrs: { containerType: ContainerType; desc: string }) => { - const pos = getPos(); - if (pos == null) return; - view.dispatch(view.state.tr.setNodeAttribute(pos, 'containerType', attrs.containerType.toLowerCase())); - const inValidDesc = Object.keys(ContainerType).includes(attrs.desc.trim().toUpperCase()); - view.dispatch(view.state.tr.setNodeAttribute(pos, 'desc', inValidDesc ? '' : attrs.desc)); - }; - - titleRoot.render( - createElement(ContainerTitle, { - desc, - containerType, - contentDom, - getReadonly: () => !view.editable, - setAttrs, - }), - ); - - return { - dom, - contentDOM: contentDom, - update: (updatedNode) => { - if ( - updatedNode.type !== initialNode.type || - updatedNode.attrs.desc !== desc || - updatedNode.attrs.containerType !== containerType - ) - return false; - - titleRoot.render( - createElement(ContainerTitle, { - desc: updatedNode.attrs.desc, - containerType: updatedNode.attrs.containerType, - contentDom, - getReadonly: () => !view.editable, - setAttrs, - }), - ); - return true; - }, - destroy() { - titleRoot.unmount(); - }, - ignoreMutation(mutation) { - if (mutation.type === 'selection' && mutation.target.nodeName === 'SPAN') return false; - if (mutation.type === 'selection' && (mutation.target as HTMLElement).classList.contains('title-container')) - return false; - return true; - }, - }; - }; -}); - -const containerInputRule = $inputRule((ctx) => { - return wrappingInputRule( - /^:::(tip|warning|info|danger|details)\s+([\S\s]+)\s?:::$/, - containerSchema.type(ctx), - (match) => ({ - containerType: match[1] || ContainerType.TIP, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - desc: match[2] || '', - }), - ); -}); - -export const wrapInContainerCommand = $command('WrapInContainer', (ctx) => () => wrapIn(containerSchema.type(ctx))); - -export const containerPlugin: MilkdownPlugin[] = [containerSchema, containerView, containerInputRule].flat(); diff --git a/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx b/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx deleted file mode 100644 index 57369f2..0000000 --- a/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC } from 'react'; - -import { updateLocationHash } from '@/utils/utils'; - -export interface AnchorProps { - id: string; - text: string; - toAnchor?: (id: string) => void; -} - -export const Anchor: FC = ({ id, text, toAnchor }) => { - const onClickAnchor = (e: React.MouseEvent) => { - e.stopPropagation(); - - if (toAnchor) { - toAnchor(id); - return; - } - - updateLocationHash(id); - }; - - return ( - - # - - ); -}; diff --git a/client/src/components/Editor/plugins/plugin-heading/index.ts b/client/src/components/Editor/plugins/plugin-heading/index.ts deleted file mode 100644 index f40b84a..0000000 --- a/client/src/components/Editor/plugins/plugin-heading/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { headingAttr, headingSchema } from '@milkdown/kit/preset/commonmark'; -import { $ctx, $view } from '@milkdown/utils'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { Anchor } from './Anchor'; - -import { headerToId } from '@/utils/utils'; - -export interface HeadingConfig { - toAnchor?: (id: string) => void; -} - -export const headingDefaultConfig: HeadingConfig = {}; - -export const headingConfig = $ctx(headingDefaultConfig, 'headingConfig'); - -export const headingView = $view(headingSchema.node, (ctx) => { - return (initialNode) => { - const dom = document.createElement(`h${initialNode.attrs.level as string}`); - const attrs = { - ...ctx.get(headingAttr.key)(initialNode), - id: initialNode.attrs.id || headerToId(initialNode.textContent), - }; - Object.entries(attrs).forEach(([key, value]) => { - dom.setAttribute(key, value as string); - }); - dom.classList.add('milkdown-heading'); - - const contentDom = document.createElement('span'); - contentDom.textContent = initialNode.textContent; - dom.appendChild(contentDom); - - const anchorContainer = document.createElement('div'); - anchorContainer.classList.add('heading-anchor-container'); - dom.appendChild(anchorContainer); - const root = createRoot(anchorContainer); - - const { toAnchor } = ctx.get(headingConfig.key); - - root.render( - createElement(Anchor, { - id: initialNode.attrs.id as string, - text: initialNode.textContent, - toAnchor, - }), - ); - - return { - dom, - contentDOM: contentDom, - update: (updatedNode) => { - if (updatedNode.type !== initialNode.type) return false; - - dom.setAttribute('id', updatedNode.attrs.id); - - root.render( - createElement(Anchor, { - id: updatedNode.attrs.id as string, - text: updatedNode.textContent, - toAnchor, - }), - ); - return true; - }, - destroy() { - root.unmount(); - }, - ignoreMutation(mutation) { - if ( - mutation.type === 'childList' && - (mutation.target as HTMLElement).classList.contains('heading-anchor-container') - ) - return true; - - // avoid typing blur - if (mutation.type === 'attributes' && (mutation.target as HTMLElement).classList.contains('heading-anchor')) - return true; - - return false; - }, - }; - }; -}); - -export const headingPlugin: MilkdownPlugin[] = [headingView, headingConfig].flat(); diff --git a/client/src/components/Editor/plugins/plugin-highlight/commnads.ts b/client/src/components/Editor/plugins/plugin-highlight/commnads.ts deleted file mode 100644 index 83d6e48..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/commnads.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Mark } from '@milkdown/kit/prose/model'; -import { $command } from '@milkdown/utils'; - -import { highlightSchema } from './index'; -import { colorPickTooltipAPI } from './slices'; - -export const toggleColorPickerCommand = $command('ToggleColorPicker', (ctx) => { - return () => (state) => { - const { - doc, - selection: { from, to }, - } = state; - const markType = highlightSchema.type(ctx); - const hasColorPicker = doc.rangeHasMark(from, to, markType); - if (hasColorPicker) { - ctx.get(colorPickTooltipAPI.key).removeColorPicker(from, to); - return true; - } - - let mark: Mark | null = null; - doc.nodesBetween(from, to, (node) => { - if (!markType.isInSet(node.marks)) return; - mark = node.marks.find((m) => m.type === markType) ?? null; - }); - - ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); - return true; - }; -}); - -export const showColorPickerCommand = $command('ToggleColorPicker', (ctx) => { - return () => (state) => { - const { - doc, - selection: { from, to }, - } = state; - - const markType = highlightSchema.type(ctx); - let mark: Mark | null = null; - doc.nodesBetween(from, to, (node) => { - if (!markType.isInSet(node.marks)) return; - mark = node.marks.find((m) => m.type === markType) ?? null; - }); - - ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); - - return true; - }; -}); diff --git a/client/src/components/Editor/plugins/plugin-highlight/index.ts b/client/src/components/Editor/plugins/plugin-highlight/index.ts deleted file mode 100644 index e7d847a..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { markRule } from '@milkdown/kit/prose'; -import { $inputRule, $markSchema, $remark } from '@milkdown/kit/utils'; -import { Mark } from 'mdast'; - -import { showColorPickerCommand, toggleColorPickerCommand } from './commnads'; -import { remarkMarkColor } from './remark-mark-color'; -import { colorPickTooltipAPI, colorOptionsConfig } from './slices'; -import { colorPickTooltip } from './tooltip'; - -export const DEFAULT_COLOR = '#ffff00'; - -export const highlightSchema = $markSchema('mark', () => { - return { - attrs: { - color: { - default: DEFAULT_COLOR, - validate: 'string', - }, - }, - parseDOM: [ - { - tag: 'mark', - getAttrs: (node: HTMLElement) => { - return { - color: node.style.backgroundColor, - }; - }, - }, - ], - toDOM: (mark) => ['mark', { style: `background-color: ${mark.attrs.color as string}` }], - parseMarkdown: { - match: (node) => node.type === 'mark', - runner: (state, node, markType) => { - const color = (node as Mark).data?.color; - state.openMark(markType, { color }); - state.next(node.children); - state.closeMark(markType); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'mark', - runner: (state, mark) => { - let color = mark.attrs.color; - if (color?.toLowerCase() === DEFAULT_COLOR.toLowerCase()) { - color = undefined; - } - state.withMark(mark, 'mark', undefined, { - data: { color }, - }); - }, - }, - }; -}); - -export const highlightInputRule = $inputRule((ctx) => { - return markRule(/(?:==)(?:\{([^}]+)\})?([^=]+)(?:==)$/, highlightSchema.type(ctx), { - getAttr: (match) => { - const color = match[1]; - return { - color: color || null, - }; - }, - }); -}); - -const markRemark = $remark('highlightColor', () => remarkMarkColor); - -export const highlightMarkerPlugin: MilkdownPlugin[] = [ - highlightSchema, - highlightInputRule, - markRemark, - colorPickTooltip, - colorPickTooltipAPI, - colorOptionsConfig, - toggleColorPickerCommand, - showColorPickerCommand, -].flat(); - -export * from './configration'; -export * from './slices'; -export * from './commnads'; diff --git a/client/src/components/Editor/plugins/plugin-highlight/slices.ts b/client/src/components/Editor/plugins/plugin-highlight/slices.ts deleted file mode 100644 index 407b0c2..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/slices.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Mark } from '@milkdown/kit/prose/model'; -import { $ctx } from '@milkdown/utils'; - -import { ColorOptions } from './view/ColorPicker'; - -export interface ColorPickTooltipAPI { - addColorPicker: (mark: Mark | null, from: number, to: number) => void; - removeColorPicker: (from: number, to: number) => void; -} - -const defaultAPI: ColorPickTooltipAPI = { - addColorPicker: () => { - //... - }, - removeColorPicker: () => { - //... - }, -}; - -export const colorPickTooltipAPI = $ctx({ ...defaultAPI }, 'colorPickTooltipAPICtx'); - -export const defaultColorOptions: ColorOptions[] = [ - { - label: 'Background', - code: 'BG', - items: [ - { - label: 'Default', - value: 'default', - }, - { - label: 'Red', - value: '#fed5d5', - }, - { - label: 'Orange', - value: '#fedfbb', - }, - { - label: 'Yellow', - value: '#fef3a1', - }, - { - label: 'Green', - value: '#e1fab1', - }, - { - label: 'Teal', - value: '#adf8e9', - }, - { - label: 'Blue', - value: '#cce2fe', - }, - { - label: 'Purple', - value: '#edddff', - }, - { - label: 'Grey', - value: '#eaecef', - }, - ], - }, -]; - -export const colorOptionsConfig = $ctx(defaultColorOptions, 'colorOptionsCtx'); diff --git a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss b/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss deleted file mode 100644 index b4c6186..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use '@/utils/utils.scss' as *; - -.milkdown-color-picker { - position: absolute; - z-index: 10; - box-shadow: var(--crepe-shadow-1); - background-color: var(--crepe-color-surface); - border-radius: $borderRadius; - &[data-show='false'] { - display: none; - } - - .color-picker-container { - width: 150px; - padding: 10px; - .group-title { - font-size: 12px; - color: $shallowTextColor; - text-align: start; - font-weight: normal; - margin-bottom: 0.5rem - } - .color-item { - @include flex-center(); - justify-content: flex-start; - padding: 5px; - border-radius: $borderRadius; - .demo { - border: 1px solid $shadowColor; - border-radius: $borderRadius; - padding: 3px; - width: 16px; - height: 16px; - font-size: 12px; - font-weight: normal; - margin-right: 1rem; - @include flex-center(); - } - .desc { - font-size: 12px; - font-weight: normal; - } - } - } - - .color-picker-listbox { - border: none; - .p-listbox-list-wrapper, - .p-listbox-list, - .p-listbox-item-group { - background-color: var(--crepe-color-surface); - } - } -} diff --git a/client/src/components/Editor/plugins/plugin-iframe/index.scss b/client/src/components/Editor/plugins/plugin-iframe/index.scss deleted file mode 100644 index d32f41f..0000000 --- a/client/src/components/Editor/plugins/plugin-iframe/index.scss +++ /dev/null @@ -1,70 +0,0 @@ -@use '@/utils/utils.scss' as *; -@use 'sass:color'; -@use '@/components/Editor/components/TooltipInput'; - -.milkdown .iframe-plugin-container { - border-radius: $borderRadius; - margin: 16px 0; - display: flex; - flex-direction: column; - align-items: start; - justify-content: center; - &.ProseMirror-selectednode { - background-color: $backgroundColor; - } - .iframe-loader { - @include flex-center(); - width: 100%; - height: 100%; - } - .iframe-plugin { - border-radius: $borderRadius; - width: fit-content; - width: 100%; - aspect-ratio: var(--aspect-video, 16 / 9); - outline: none; - border: none; - &.loading { - display: none; - } - } - .iframe-plugin-link { - cursor: pointer; - color: $contentTextColor; - font-weight: light; - height: 32px; - padding: 4px 8px; - border-top-left-radius: .25rem; - border-top-right-radius: .25rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - font-size: 16px; - text-decoration: none; - background-color: $buttonBgcColor; - &:hover { - background-color: $buttonHoverBgcColor; - } - span { - font-size: 14px; - } - } -} - -.iframe-link-tooltip { - .p-tooltip-arrow { - display: none; - } - .p-tooltip-text { - background-color: $backgroundColor; - padding: 10px; - @include flex-center(); - input { - height: 24px; - margin-right: 1rem; - background-color: $backgroundColor; - color: $contentTextColor; - } - } -} \ No newline at end of file diff --git a/client/src/components/Editor/plugins/plugin-iframe/index.ts b/client/src/components/Editor/plugins/plugin-iframe/index.ts deleted file mode 100644 index 4e760ed..0000000 --- a/client/src/components/Editor/plugins/plugin-iframe/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { $remark, $nodeSchema, $inputRule, $view } from '@milkdown/kit/utils'; -import { InputRule } from 'prosemirror-inputrules'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import directive from 'remark-directive'; - -import { IframeView } from './IframeView'; - -const remarkPluginId = 'Iframe'; -const remarkDirective = $remark(remarkPluginId, () => directive); - -export const iframeBlockSchema = $nodeSchema('iframe', () => ({ - group: 'block', // Block-level node - atom: true, // Cannot be split - isolating: true, // Cannot be merged with adjacent nodes - marks: '', // No marks allowed - attrs: { - src: { default: 'https://example.com' }, // URL attribute - }, - defining: true, - code: true, - parseDOM: [ - { - tag: 'iframe', - getAttrs: (dom) => ({ - src: dom.getAttribute('src'), - }), - }, - ], - parseMarkdown: { - match: (node) => node.type === 'leafDirective' && node.name === 'iframe', - runner: (state, node, type) => { - state.addNode(type, { src: (node.attributes as { src: string }).src }); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'iframe', - runner: (state, node) => { - state.addNode('leafDirective', undefined, undefined, { - name: 'iframe', - attributes: { src: node.attrs.src }, - }); - }, - }, -})); - -const iframeView = $view(iframeBlockSchema.node, () => { - return (initialNode, view, getPos) => { - const dom = document.createElement('div'); - dom.style.borderRadius = '5px'; - const root = createRoot(dom); - - const setAttrs = ({ src }: { src: string }) => { - if (!view.editable) return; - const pos = getPos(); - if (pos == null) return; - view.dispatch(view.state.tr.setNodeAttribute(pos, 'src', src)); - }; - - root.render(createElement(IframeView, { readonly: !view.editable, src: initialNode.attrs.src, setAttrs })); - - return { - dom, - update: (updatedNode) => { - if (updatedNode.type !== initialNode.type) return false; - - root.render(createElement(IframeView, { readonly: !view.editable, src: updatedNode.attrs.src, setAttrs })); - return true; - }, - destroy() { - root.unmount(); - }, - }; - }; -}); - -const iframeInputRule = $inputRule( - (ctx) => - new InputRule(/:iframe\{src="(?[^"]+)?"?\}/, (state, match, start, end) => { - const [okay, src = ''] = match; - const { tr } = state; - if (okay) { - tr.replaceWith(start - 1, end, iframeBlockSchema.type(ctx).create({ src })); - } - return tr; - }), -); - -export const iframePlugin: MilkdownPlugin[] = [remarkDirective, iframeBlockSchema, iframeView, iframeInputRule].flat(); diff --git a/client/src/components/EditorContainer/EditorContainer.tsx b/client/src/components/EditorContainer/EditorContainer.tsx index c29691e..1e33ffe 100644 --- a/client/src/components/EditorContainer/EditorContainer.tsx +++ b/client/src/components/EditorContainer/EditorContainer.tsx @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { MilkdownProvider } from '@milkdown/react'; import Split from '@uiw/react-split'; import { useMemo, useRef, FC } from 'react'; import { useSelector } from 'react-redux'; @@ -76,30 +75,9 @@ export const EditorContainer: FC = () => { >
- - - - } - /> - - - - } - /> - - - - } - /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/components/Footer/Footer.scss b/client/src/components/Footer/Footer.scss index e97605e..4ea88ea 100644 --- a/client/src/components/Footer/Footer.scss +++ b/client/src/components/Footer/Footer.scss @@ -20,6 +20,29 @@ } } } + .workspace-mode-tag { + display: inline-flex; + gap: 4px; + align-items: center; + height: 20px; + padding: 0 7px; + color: $headerTextColor; + font-size: 11px; + font-weight: 500; + line-height: 1; + background: transparent; + border: 1px solid $panelBorderColor; + .p-tag-icon { + display: inline-flex; + align-items: center; + margin-right: 0; + font-size: 12px; + line-height: 1; + } + .p-tag-value { + line-height: 1; + } + } } .right-group { margin-right: 0.5rem; diff --git a/client/src/components/Footer/Footer.tsx b/client/src/components/Footer/Footer.tsx index e938635..5008372 100644 --- a/client/src/components/Footer/Footer.tsx +++ b/client/src/components/Footer/Footer.tsx @@ -3,11 +3,13 @@ import BallotIcon from '@mui/icons-material/BallotOutlined'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExitOutlined'; import FullscreenIcon from '@mui/icons-material/FullscreenOutlined'; import MirrorIcon from '@mui/icons-material/ImportContactsOutlined'; +import { Tag } from 'primereact/tag'; import { FC } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { Icon } from '@/components/Icon/Icon'; +import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { selectMirrorCollapse, selectNarrowMode, @@ -26,6 +28,7 @@ export const Footer: FC = () => { const mirrorCollapse = useSelector(selectMirrorCollapse); const narrowMode = useSelector(selectNarrowMode); const serverStatus = useSelector(selectServerStatus); + const workspaceMode = useSelector(selectWorkspaceMode); const navigate = useNavigate(); const switchNarrowMode = useSwitchNarrowMode(); @@ -48,11 +51,18 @@ export const Footer: FC = () => { onClick={() => { if (serverStatus === ServerStatus.VERSION_MISMATCHE) { void navigate('/internal/version-mismatch'); - } else if (serverStatus === ServerStatus.RUNNING) { + } else { void navigate('/internal/guide'); } }} /> +
void; + onCommitMsgBodyChange: (commitMsgBody: string) => void; +} + +export const CommitMsgBox: FC = ({ disabled, onCommitMsgTitleChange, onCommitMsgBodyChange }) => { + const [commitMsgTitle, setCommitMsgTitle] = useState(''); + const [commitMsgBody, setCommitMsgBody] = useState(''); + + return ( +
+
Title
+ { + setCommitMsgTitle(event.target.value); + onCommitMsgTitleChange(event.target.value); + }} + className="commit-msg-input" + placeholder="commit message title" + /> +
Body
+ { + setCommitMsgBody(event.target.value); + onCommitMsgBodyChange(event.target.value); + }} + className="commit-msg-input" + placeholder="commit message body" + /> +
+ ); +}; diff --git a/client/src/components/GitBox/GitBox.scss b/client/src/components/GitBox/GitBox.scss index 714a0e0..224055b 100644 --- a/client/src/components/GitBox/GitBox.scss +++ b/client/src/components/GitBox/GitBox.scss @@ -1,4 +1,4 @@ -@use "@/utils/utils.scss" as *; +@use '@/utils/utils.scss' as *; .git-box { .no-git-service { @@ -51,7 +51,7 @@ justify-content: flex-end; align-items: center; border-bottom: 1px solid $panelBorderColor; - .icon-wrapper { + .icon-wrapper + .icon-wrapper { margin-left: 10px; } } @@ -152,7 +152,7 @@ position: relative; &:hover { &::after { - content: ""; + content: ''; position: absolute; left: 0; top: 0; @@ -167,6 +167,7 @@ } .commit-msg-box { + margin-top: 1rem; div { font-weight: bold; margin: 1rem 0 0.5rem 0; @@ -184,3 +185,11 @@ min-height: 200px; } } + +.github-changes-box { + .no-git-service { + height: auto; + padding: 0.5rem; + color: #c0392b; + } +} diff --git a/client/src/components/GitBox/GitBox.tsx b/client/src/components/GitBox/GitBox.tsx index d241bef..0bae310 100644 --- a/client/src/components/GitBox/GitBox.tsx +++ b/client/src/components/GitBox/GitBox.tsx @@ -9,11 +9,10 @@ import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import FileOpenIcon from '@mui/icons-material/FileOpenOutlined'; import RemoveIcon from '@mui/icons-material/RemoveOutlined'; import UndoIcon from '@mui/icons-material/UndoOutlined'; -import { InputText } from 'primereact/inputtext'; -import { InputTextarea } from 'primereact/inputtextarea'; import { ProgressSpinner } from 'primereact/progressspinner'; import React, { FC, useCallback, useState } from 'react'; +import { CommitMsgBox } from '@/components/GitBox/CommitMsgBox'; import { Icon } from '@/components/Icon/Icon'; import { useGetDocSubItemsQuery } from '@/redux-api/docs'; import { @@ -39,41 +38,6 @@ const defaultStatus = { noGit: true, }; -interface CommitMsgBoxProps { - onCommitMsgTitleChange: (commitMsgTitle: string) => void; - onCommitMsgBodyChange: (commitMsgBody: string) => void; -} -const CommitMsgBox: FC = ({ onCommitMsgTitleChange, onCommitMsgBodyChange }) => { - const [commitMsgTitle, setCommitMsgTitle] = useState(''); - const [commitMsgBody, setCommitMsgBody] = useState(''); - - return ( -
-
Title
- { - setCommitMsgTitle(e.target.value); - onCommitMsgTitleChange(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message title" - /> -
Body
- { - setCommitMsgBody(e.target.value); - onCommitMsgBodyChange(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message body" - /> -
- ); -}; - export const GitBox: FC = () => { const { navigate, curPath } = useCurPath(); diff --git a/client/src/components/GitBox/GitHubChangesBox.tsx b/client/src/components/GitBox/GitHubChangesBox.tsx new file mode 100644 index 0000000..5c31792 --- /dev/null +++ b/client/src/components/GitBox/GitHubChangesBox.tsx @@ -0,0 +1,295 @@ +import AddIcon from '@mui/icons-material/AddOutlined'; +import FileOpenIcon from '@mui/icons-material/FileOpenOutlined'; +import RemoveIcon from '@mui/icons-material/RemoveOutlined'; +import UndoIcon from '@mui/icons-material/UndoOutlined'; +import { Button } from 'primereact/button'; +import { Dialog } from 'primereact/dialog'; +import { FC, useState, type ElementType } from 'react'; +import { useSelector } from 'react-redux'; + +import type { MutationResult, WorkspaceChange } from '@markdown-editor/github-workspace'; + +import { CommitMsgBox } from '@/components/GitBox/CommitMsgBox'; +import { Icon } from '@/components/Icon/Icon'; +import { usePublishGitHubWorkspaceMutation } from '@/redux-api/github'; +import { selectGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import { getVisibleLogicalPath } from '@/utils/githubWorkspace'; +import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; +import { useCurPath } from '@/utils/hooks/docHooks'; +import { useDeleteTab, useRenameTabs, useSaveDoc } from '@/utils/hooks/reduxHooks'; +import Toast from '@/utils/Toast'; +import { confirm, normalizePath } from '@/utils/utils'; + +import './GitBox.scss'; + +const ChangeList: FC<{ + changes: WorkspaceChange[]; + actionIcon: ElementType; + actionTitle: string; + onAction: (id: string) => void; + onOpen?: (change: WorkspaceChange) => void; + onRevert?: (id: string) => void; +}> = ({ changes, actionIcon, actionTitle, onAction, onOpen, onRevert }) => + changes.length ? ( +
    + {changes.map((change, index) => ( +
  • +
    + {change.path} +
    +
    + {onOpen && change.status !== 'DELETED' && change.kind === 'file' && change.path.endsWith('.md') && ( + { + onOpen(change); + }} + /> + )} + {onRevert && ( + { + onRevert(change.groupId); + }} + /> + )} + { + onAction(change.groupId); + }} + /> + {change.status[0]} +
    +
  • + ))} +
+ ) : ( +
+ + No changes +
+ ); + +export const GitHubChangesBox: FC = () => { + const config = useSelector(selectGitHubWorkspaceConfig); + const workspace = useGitHubWorkspaceSnapshot(); + const { navigate, curPath } = useCurPath(); + const saveDoc = useSaveDoc(); + const renameTabs = useRenameTabs(); + const deleteTabs = useDeleteTab(); + const [publishDialogVisible, setPublishDialogVisible] = useState(false); + const [publishTitle, setPublishTitle] = useState(''); + const [publishBody, setPublishBody] = useState(''); + const [publishing, setPublishing] = useState(false); + const [publish] = usePublishGitHubWorkspaceMutation(); + const unstagedChanges = workspace.unstagedChanges; + const stagedChanges = workspace.stagedChanges; + + const applyPathMappings = (result: MutationResult) => { + const operations = result.pathMappings + .filter((mapping) => mapping.kind === 'file' && mapping.oldPath.endsWith('.md')) + .map((mapping) => ({ + oldPath: getVisibleLogicalPath(config, mapping.oldPath), + newPath: getVisibleLogicalPath(config, mapping.newPath), + isFile: true, + })); + if (operations.length) renameTabs(operations); + }; + + const closeDiscardedUntrackedTabs = async (changes: WorkspaceChange[]) => { + const deletedIds = new Set( + changes.filter((change) => change.status === 'DELETED').map((change) => change.oldEntry?.id), + ); + const paths = changes + .filter( + (change) => + (change.status === 'UNTRACKED' || change.status === 'ADDED') && + change.kind === 'file' && + change.path.endsWith('.md') && + !deletedIds.has(change.newEntry?.id), + ) + .map((change) => getVisibleLogicalPath(config, change.path)); + if (paths.length) await deleteTabs(paths, { force: true }); + }; + + const openFile = (repositoryPath: string) => { + const logicalPath = getVisibleLogicalPath(config, repositoryPath); + if (normalizePath(curPath) === logicalPath) return; + void saveDoc(); + void navigate(`/article/${logicalPath}`); + }; + + const publishChanges = async () => { + if (publishing || !stagedChanges.length) return; + if (!publishTitle.trim()) { + Toast.warn('Commit title cannot be blank'); + return; + } + let token = ''; + try { + setPublishing(true); + const plan = githubWorkspaceStore.beginPublish(); + token = plan.token; + const snapshot = await publish({ config, plan, title: publishTitle.trim(), body: publishBody }).unwrap(); + await githubWorkspaceStore.completePublish(snapshot, token); + setPublishDialogVisible(false); + Toast('Published to GitHub'); + } catch (error) { + if (token) githubWorkspaceStore.abortPublish(token); + const message = (error as { message?: string }).message ?? 'Failed to publish to GitHub'; + Toast.error(message); + } finally { + setPublishing(false); + } + }; + + const discardWorkingChanges = async () => { + if (!(await confirm({ message: 'Discard every working-space change?' }))) return; + const discardedChanges = workspace.unstagedChanges; + const groupIds = [...new Set(discardedChanges.map((change) => change.groupId))]; + const result = await githubWorkspaceStore.restoreWorking(groupIds); + applyPathMappings(result); + await closeDiscardedUntrackedTabs(discardedChanges); + }; + + const discardWorkingChange = async (groupId: string) => { + if (!(await confirm({ message: 'Are you sure to restore?' }))) return; + const discardedChanges = workspace.unstagedChanges.filter((change) => change.groupId === groupId); + const result = await githubWorkspaceStore.restoreWorking([groupId]); + applyPathMappings(result); + await closeDiscardedUntrackedTabs(discardedChanges); + }; + + return ( +
+
+ { + setPublishTitle(''); + setPublishBody(''); + setPublishDialogVisible(true); + }} + /> +
+ { + if (!publishing) setPublishDialogVisible(false); + }} + footer={ +
+
+ } + > + {publishDialogVisible && ( + + )} +
+
+
+
Staged
+
+ { + const groupIds = [...new Set(workspace.stagedChanges.map((change) => change.groupId))]; + void githubWorkspaceStore.unstage(groupIds); + }} + /> +
+
+ void githubWorkspaceStore.unstage([id])} + onOpen={(change) => { + openFile(change.path); + }} + /> +
+
+
+
Changes
+
+ void discardWorkingChanges()} + /> + void githubWorkspaceStore.stageAll()} + /> +
+
+ void githubWorkspaceStore.stage([id])} + onOpen={(change) => { + openFile(change.path); + }} + onRevert={(id) => void discardWorkingChange(id)} + /> +
+
+ ); +}; diff --git a/client/src/components/Header/Header.scss b/client/src/components/Header/Header.scss index 2c92022..d2a49b1 100644 --- a/client/src/components/Header/Header.scss +++ b/client/src/components/Header/Header.scss @@ -23,4 +23,58 @@ .icon-wrapper { margin-left: 0.8rem; } + + .github-account { + position: relative; + margin-left: 0.8rem; + + &:hover, + &:focus-within { + .github-logout-popup { + visibility: visible; + opacity: 1; + } + } + } + + .github-avatar-link { + display: flex; + } + + .github-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + } + + .github-logout-popup { + position: absolute; + top: 100%; + right: 0; + visibility: hidden; + opacity: 0; + padding-top: 0.35rem; + transition: opacity 0.15s ease-in-out; + } + + .github-logout-button { + display: flex; + gap: 0.4rem; + align-items: center; + padding: 0.45rem 0.65rem; + color: $headerTextColor; + font-size: 0.8rem; + white-space: nowrap; + cursor: pointer; + background: $panelBackgroundColor; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + box-shadow: $frameShadow; + + &:hover, + &:focus-visible { + background: $hoverBgcColor; + } + } } diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index 0524f17..f56ada6 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Menu } from 'primereact/menu'; import { MenuItem } from 'primereact/menuitem'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { DocSearch } from '../DocSearch/DocSearch'; @@ -9,7 +9,10 @@ import { ImgManagement } from '../ImgManagement/ImgManagement'; import { Icon } from '@/components/Icon/Icon'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; +import { selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; import { selectGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import { activateGitHubImageWorkspace } from '@/utils/githubImages'; +import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc, useSwitchReadonlyMode, useSwitchTheme } from '@/utils/hooks/reduxHooks'; import { nextTick } from '@/utils/utils'; @@ -18,14 +21,27 @@ import './Header.scss'; // eslint-disable-next-line @typescript-eslint/naming-convention export default function Header() { const { readonly, theme } = useSelector(selectGlobalOpts); + const workspace = useSelector(selectGithubWorkspace); + const workspaceMode = workspace.mode; const { isDirty, type, contentIdent } = useSelector(selectCurDoc); const themeMenuRef = useRef(null); + const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, logoutGitHub, startGitHubLogin } = useGitHubLogin(); const saveDoc = useSaveDoc(); const switchReadonlyMode = useSwitchReadonlyMode(); const switchTheme = useSwitchTheme(); + useEffect(() => { + activateGitHubImageWorkspace(workspaceMode === 'github' ? workspace.config : undefined); + }, [ + workspace.config.branch, + workspace.config.docsRoot, + workspace.config.owner, + workspace.config.repo, + workspaceMode, + ]); + const themeMenuItems: MenuItem[] = [ { label: 'Themes', @@ -70,7 +86,8 @@ export default function Header() { return (
- + {/* TODO: Re-enable GitHub search when authenticated /search/code responses support CORS, or add a proxy. */} +
@@ -99,6 +116,34 @@ export default function Header() { themeMenuRef.current?.toggle(e); }} /> + {githubLogin && githubAvatarUrl ? ( +
+ + {`${githubLogin}'s + +
+ +
+
+ ) : ( + + )}
diff --git a/client/src/components/ImgManagement/ImgManagement.scss b/client/src/components/ImgManagement/ImgManagement.scss index 9c2bd88..55369d0 100644 --- a/client/src/components/ImgManagement/ImgManagement.scss +++ b/client/src/components/ImgManagement/ImgManagement.scss @@ -1,4 +1,4 @@ -@use "@/utils/utils.scss" as *; +@use '@/utils/utils.scss' as *; .img-management-container { #icon-img-management-icon { @@ -67,6 +67,18 @@ flex-shrink: 0; } + .img-preview-state { + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + color: var(--shallowTextColor); + font-size: 0.75rem; + text-align: center; + flex-shrink: 0; + } + .img-actions { display: flex; flex-shrink: 0; diff --git a/client/src/components/ImgManagement/ImgManagement.tsx b/client/src/components/ImgManagement/ImgManagement.tsx index 97aa2c3..7798067 100644 --- a/client/src/components/ImgManagement/ImgManagement.tsx +++ b/client/src/components/ImgManagement/ImgManagement.tsx @@ -1,3 +1,4 @@ +import { WORKSPACE_ASSET_SEGMENT } from '@markdown-editor/core'; import { Button } from 'primereact/button'; import { DataView } from 'primereact/dataview'; import { Dialog } from 'primereact/dialog'; @@ -18,6 +19,15 @@ import { useLazyGetImgRefDocsQuery, } from '@/redux-api/img'; import { selectCurContent } from '@/redux-feature/curDocSlice'; +import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; +import { + getGitHubAssetsPath, + getGitHubImageUrl, + isSupportedImagePath, + loadGitHubAssetsDirectory, + removeGitHubImageUrl, +} from '@/utils/githubImages'; +import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import Toast from '@/utils/Toast'; import { confirm, normalizePath } from '@/utils/utils'; @@ -44,7 +54,7 @@ function extractImageUrls(markdown: string): Set { const MS_PER_SECOND = 1000; -function formatDate(timestamp: number): string { +function formatDate(timestamp?: number): string { if (!timestamp) return '-'; return new Date(timestamp * MS_PER_SECOND).toLocaleDateString(undefined, { year: 'numeric', @@ -55,12 +65,50 @@ function formatDate(timestamp: number): string { }); } +/** + * Resolves previews through the same mode-aware path used by the editor. + * In GitHub mode, remote SHAs use an authenticated blob request while new + * working-tree images render directly from their staged base64 content. + */ +const WorkspaceImage: FC<{ image: ImgListItem }> = ({ image }) => { + const [src, setSrc] = useState(); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + setSrc(undefined); + setError(false); + void Promise.resolve(getImageUrl(image.url)).then( + (resolvedUrl) => { + if (active) setSrc(resolvedUrl); + }, + () => { + if (active) setError(true); + }, + ); + return () => { + active = false; + }; + }, [image.url]); + + if (error) return
Preview unavailable
; + if (!src) return
Loading...
; + return {image.fileName}; +}; + export const ImgManagement: FC = () => { const [showImgManagementModal, setShowImgManagementModal] = useState(false); const [filterMode, setFilterMode] = useState('used'); + const [githubListLoading, setGithubListLoading] = useState(false); + const [githubListError, setGithubListError] = useState(); + const workspace = useSelector(selectGithubWorkspace); + const githubSnapshot = useGitHubWorkspaceSnapshot(); + const isGitHubMode = workspace.mode === 'github'; - const { data: images = [], refetch: refetchImgList } = useGetImgListQuery(undefined, { - skip: !showImgManagementModal, + // Local mode continues to use the image server. GitHub mode deliberately + // skips this API because its image inventory comes from the workspace tree. + const { data: localImages = [], refetch: refetchImgList } = useGetImgListQuery(undefined, { + skip: !showImgManagementModal || isGitHubMode, }); const [deleteImg] = useDeleteWorkspaceImgMutation(); const [fetchRefDocs] = useLazyGetImgRefDocsQuery(); @@ -70,6 +118,24 @@ export const ImgManagement: FC = () => { const curContent = useSelector(selectCurContent); const navigate = useNavigate(); const usedUrls = useMemo(() => extractImageUrls(curContent || ''), [curContent]); + const githubAssetsPath = getGitHubAssetsPath(workspace.config); + // listDirectory reads the effective working tree, so this list immediately + // includes unstaged/staged uploads and excludes pending deletions. DataView + // paginates this in-memory list below; no GitHub request is made per page. + const githubImages = useMemo( + () => + githubWorkspaceStore + .listDirectory(githubAssetsPath) + .filter((entry) => entry.kind === 'file' && isSupportedImagePath(entry.path)) + .map((entry) => { + const fileName = entry.path.split('/').at(-1) ?? entry.path; + return { fileName, url: getGitHubImageUrl(workspace.config, fileName) }; + }) + .sort((left, right) => left.fileName.localeCompare(right.fileName)), + [githubAssetsPath, githubSnapshot.entries], + ); + const images = isGitHubMode ? githubImages : localImages; + const githubWorkspaceKey = getGitHubWorkspaceKey(workspace.config); const handleDelete = useCallback( async (fileName: string) => { @@ -77,9 +143,18 @@ export const ImgManagement: FC = () => { message: `Are you sure you want to delete "${fileName}"?`, }); if (!confirmed) return; - await deleteImg(fileName); + try { + if (isGitHubMode) { + await githubWorkspaceStore.delete(`${githubAssetsPath}/${fileName}`); + removeGitHubImageUrl(workspace.config, getGitHubImageUrl(workspace.config, fileName)); + } else { + await deleteImg(fileName).unwrap(); + } + } catch (error) { + Toast.error((error as Error).message ?? 'Failed to delete image.'); + } }, - [deleteImg], + [deleteImg, githubAssetsPath, isGitHubMode, workspace.config], ); const handleFindRefs = useCallback( @@ -105,41 +180,82 @@ export const ImgManagement: FC = () => { const filteredImages = useMemo(() => { if (filterMode === 'all') return images; return images.filter((img) => { - const isUsed = usedUrls.has(img.url); + const isUsed = + usedUrls.has(img.url) || (isGitHubMode && usedUrls.has(`${WORKSPACE_ASSET_SEGMENT}${img.fileName}`)); return filterMode === 'used' ? isUsed : !isUsed; }); - }, [images, filterMode, usedUrls]); + }, [filterMode, images, isGitHubMode, usedUrls]); useEffect(() => { if (!showImgManagementModal) return; - refetchImgList(); - }, [refetchImgList, usedUrls]); + if (!isGitHubMode) { + void refetchImgList(); + return; + } + let active = true; + setGithubListLoading(true); + setGithubListError(undefined); + // Large repositories may initialize with only the docs root's direct + // children. Fetch the _assets Git tree once on demand, then merge it into + // the workspace without overwriting staged or unstaged changes. + void loadGitHubAssetsDirectory(workspace.config).then( + () => { + if (active) setGithubListLoading(false); + }, + (error: unknown) => { + if (!active) return; + setGithubListLoading(false); + setGithubListError((error as Error).message ?? 'Failed to load images.'); + }, + ); + return () => { + active = false; + }; + }, [ + isGitHubMode, + githubSnapshot.baseCommitSha, + githubSnapshot.persistenceLoaded, + refetchImgList, + showImgManagementModal, + usedUrls, + workspace.config.branch, + workspace.config.docsRoot, + workspace.config.owner, + workspace.config.repo, + ]); + + useEffect(() => { + if (isGitHubMode) setRefDocsFor(null); + }, [isGitHubMode]); const itemTemplate = (img: ImgListItem, index: number) => { - const isUsed = usedUrls.has(img.url); + const isUsed = usedUrls.has(img.url) || (isGitHubMode && usedUrls.has(`${WORKSPACE_ASSET_SEGMENT}${img.fileName}`)); + const itemKey = isGitHubMode ? `${githubWorkspaceKey}:${img.url}` : img.url; return ( -
+
- {img.fileName} +
{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 ( + + ); + } + + if (hasWorkspaceError) { + return ( + + ); + } + + if (!githubWorkspaceConfig.owner || !githubWorkspaceConfig.repo || !githubWorkspaceConfig.branch) { + return
Choose a GitHub workspace in Settings.
; + } + + return ( + + ); + } + + if (isSettingsFetching) { + return ; + } + + if (!settings?.docRootPath) { + return ( + + ); + } + + if (hasWorkspaceError) { + return
Oops, something went wrong while loading the workspace.
; + } + + return ( + + ); + }; + return (
- + {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 && ( + {`${githubLogin}'s + )} + 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 && ( +
+
+
+ + { + setNewRepoName(event.target.value); + }} + /> + { + setNewRepoDescription(event.target.value); + }} + /> + +
+ + ); +}; 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 }); - }} - /> - -
- -
{gitInfoContent}
-
-
- - { - 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 }); + }} + /> + +
+ +
{gitInfoContent}
+
+
+ + { + 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={
-