diff --git a/README.md b/README.md index 6d33ae3..7a8f67e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Yet another IDE for competitive programming + [X] C++ + [ ] Python - [X] Coding +- [X] Keep codes after close app - [X] Compile, Run and Check + [X] C++ + [X] Python diff --git a/package.json b/package.json index 3a7eba3..f5f09c3 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "clsx": "^2.0.0", "codemirror": "^6.0.1", "codemirror-languageserver": "^1.11.0", + "crc": "^4.3.2", "events": "^3.3.0", "framer-motion": "^10.16.5", "immer": "^10.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaa6845..65c2457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ dependencies: codemirror-languageserver: specifier: ^1.11.0 version: 1.11.0(@codemirror/language@6.9.2)(@lezer/common@1.1.1) + crc: + specifier: ^4.3.2 + version: 4.3.2 events: specifier: ^3.3.0 version: 3.3.0 @@ -3324,6 +3327,16 @@ packages: typescript: 5.2.2 dev: false + /crc@4.3.2: + resolution: {integrity: sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==} + engines: {node: '>=12'} + peerDependencies: + buffer: '>=6.0.3' + peerDependenciesMeta: + buffer: + optional: true + dev: false + /crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} dev: false diff --git a/src/components/codemirror/index.tsx b/src/components/codemirror/index.tsx index ba70eff..18d0d97 100644 --- a/src/components/codemirror/index.tsx +++ b/src/components/codemirror/index.tsx @@ -6,17 +6,21 @@ import { basicSetup } from "codemirror" import { LspProvider } from "./language" import { Extension } from "@codemirror/state" import { KeymapProvider } from "./keymap" -import { Atom, PrimitiveAtom, useSetAtom } from "jotai" +import { Atom, PrimitiveAtom } from "jotai" import { Source } from "@/store/source" import { useImmerAtom } from "jotai-immer" import { concat, map } from "lodash" import "@fontsource/jetbrains-mono" import useExtensionCompartment, { generateCommonConfigurationExtension } from "@/hooks/useExtensionCompartment" +import useTimeoutInvoke from "@/hooks/useTimeoutInvoke" +import * as cache from "@/lib/fs/cache" +import { useMitt } from "@/hooks/useMitt" type CodemirrorProps = { className?: string + id: number + title: string sourceAtom: PrimitiveAtom - changedStatusAtom: PrimitiveAtom lspAtom: Atom> keymapAtom: Atom> } @@ -27,7 +31,6 @@ const Codemirror = memo((props: CodemirrorProps) => { const cm = useRef(null) const [source, patchSource] = useImmerAtom(props.sourceAtom) - const setChangeStatus = useSetAtom(props.changedStatusAtom) const configurableExtensions = concat( [ @@ -37,6 +40,17 @@ const Codemirror = memo((props: CodemirrorProps) => { generateCommonConfigurationExtension(cm), ) + const [doCacheOnTimeout, , cancelTimeoutCache] = useTimeoutInvoke(() => { + cache.updateCache(props.id, props.title, source) + }, 1000) + useMitt("cache", (id) => { + if (id == props.id || id == -1) doCacheOnTimeout({} as never) + }) + + useEffect(()=>{ + doCacheOnTimeout({} as never) + }, [props.title]) + useEffect(() => { if (parentRef.current == null) return let isDestroy = false @@ -65,10 +79,12 @@ const Codemirror = memo((props: CodemirrorProps) => { }), EditorView.updateListener.of((e) => { if (!e.docChanged) return + // update program status patchSource((prev) => { prev.code.source = e.state.doc.toString() }) - setChangeStatus(true) + // cache file + doCacheOnTimeout({} as never) }), ], doc: source.code.source ?? "", @@ -80,12 +96,13 @@ const Codemirror = memo((props: CodemirrorProps) => { if (cmClosure != null) { cmClosure.destroy() cmClosure = null + cancelTimeoutCache() } else { isDestroy = true } } }, [parentRef]) - return
+ return
}) export default Codemirror diff --git a/src/components/runner/index.tsx b/src/components/runner/index.tsx index 84c2fa6..393caff 100644 --- a/src/components/runner/index.tsx +++ b/src/components/runner/index.tsx @@ -23,7 +23,7 @@ import EmptyRunner from "./empty" export default function Runnner({ className }: { className?: string }) { const activeId = useAtomValue(activeIdAtom) if (activeId == -1) { - return + return } return } @@ -84,6 +84,7 @@ function RunnerContent(props: { className?: string; activeIdAtom: Atom } const testcaseList = testcases.map((atom, index) => ( dispatchTestcases({ type: "remove", atom })} diff --git a/src/components/runner/single.tsx b/src/components/runner/single.tsx index 7ad7a4a..59c48ce 100644 --- a/src/components/runner/single.tsx +++ b/src/components/runner/single.tsx @@ -25,6 +25,7 @@ type SingleRunnerProps = { timeLimitsAtom: Atom memoryLimitsAtom: Atom checkerAtom: Atom + id: number taskId: string onDelete: () => void } @@ -67,8 +68,17 @@ export default function SingleRunner(props: SingleRunnerProps) { const inputAtom = useMemo(() => focusAtom(props.testcaseAtom, (optic) => optic.prop("input")), [props.testcaseAtom]) const outputAtom = useMemo(() => focusAtom(props.testcaseAtom, (optic) => optic.prop("output")), [props.testcaseAtom]) - const [input, setInput] = useAtom(inputAtom) - const [output, setOutput] = useAtom(outputAtom) + const [input, setInputAtom] = useAtom(inputAtom) + const [output, setOutputAtom] = useAtom(outputAtom) + + function setInput(inp: string){ + setInputAtom(inp) + emit('cache', props.id) + } + function setOutput(oup: string){ + setOutputAtom(oup) + emit('cache', props.id) + } const [actualStdout, setActualStdout] = useState("") const [actualStderr, setActualStderr] = useState("") @@ -80,6 +90,8 @@ export default function SingleRunner(props: SingleRunnerProps) { useEffect(() => { setJudgeStatus("UK") setCheckerReport("") + setActualStderr("") + setActualStdout("") }, [props.sourceAtom]) useMitt( diff --git a/src/hooks/useMitt.ts b/src/hooks/useMitt.ts index 993c4a5..2e26487 100644 --- a/src/hooks/useMitt.ts +++ b/src/hooks/useMitt.ts @@ -3,6 +3,7 @@ import { DependencyList, useEffect } from "react" type Events = { fileMenu: "new" | "newContest" | "open" | "openContest" | "save" | "saveAs" run: "all" | string + cache: number } const emitter = mitt() diff --git a/src/hooks/useTimeoutInvoke.ts b/src/hooks/useTimeoutInvoke.ts new file mode 100644 index 0000000..c460420 --- /dev/null +++ b/src/hooks/useTimeoutInvoke.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react" + +export default function useTimeoutInvoke( + call: (params: T) => void, + timeout: number, +): [(params: T) => void, (params: T) => void, () => void] { + const timer = useRef(null) + const param = useRef(null) + const caller = useRef<(params: T)=>void>() + + useEffect(()=>{ + caller.current = call + }, [call]) + + function cancel() { + if (timer.current) { + clearTimeout(timer.current) + timer.current = null + } + } + function delay(params: T) { + param.current = params + cancel() + timer.current = setTimeout(() => { + if (timer.current) { + clearTimeout(timer.current) + } + caller.current!(param.current!) + }, timeout) + } + function invokeNow(params: T) { + cancel() + caller.current!(params) + } + + return [delay, invokeNow, cancel] +} diff --git a/src/lib/fs/cache.ts b/src/lib/fs/cache.ts new file mode 100644 index 0000000..d397e74 --- /dev/null +++ b/src/lib/fs/cache.ts @@ -0,0 +1,58 @@ +import { path, fs } from "@tauri-apps/api" +import { Source } from "@/store/source" +import * as log from "tauri-plugin-log-api" + +async function getDataDir() { + const filesDir = await path.join(await path.appDataDir(), "files") + if (!(await fs.exists(filesDir))) { + await fs.createDir(filesDir) + } + return filesDir +} + +async function getFilePath(id: number): Promise { + const dir = await getDataDir() + const file = await path.join(dir, `${id}.dat`) + return file +} + +export async function updateCache(id: number, title: string, src: Source) { + const file = await getFilePath(id) + log.info(`update cache ${id} to path ${file}`) + fs.writeTextFile(file, JSON.stringify({ src, title })) +} + +export async function dropCache(id: number) { + const file = await getFilePath(id) + log.info(`drop cache ${id} (${file})`) + if ((await fs.exists(file))) { + await fs.removeFile(file) + } +} + +export async function dropAll() { + const dir = await getDataDir() + const files = await fs.readDir(dir, { recursive: false }) + const tasks = files.map(async (p) => { + if (p.children == null) { + await fs.removeFile(p.path) + } + }) + await Promise.all(tasks) +} + +async function recoverFromFile(file: string): Promise<[string,Source]> { + const content = await fs.readTextFile(file) + const obj = JSON.parse(content) + return [obj.title, obj.src] +} + +export async function recoverAllCache(): Promise<[string, Source][]> { + const dir = await getDataDir() + const files = (await fs.readDir(dir, { recursive: false })) + .filter((p) => p.children == null && p.name != null && p.name.endsWith(".dat")) + .map((e) => e.path!) + + const tasks = files.map(recoverFromFile) + return await Promise.all(tasks) +} diff --git a/src/main.tsx b/src/main.tsx index 71a818b..1aa6c90 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,7 +11,7 @@ import { attachConsole } from "tauri-plugin-log-api" import { loadSettingsStore } from "./store/setting" import { LanguageMode, isDebug } from "./lib/ipc" import { useCompetitiveCompanion } from "./hooks/useCompetitiveCompanion" -import { Source, useAddSource } from "./store/source" +import { Source, useAddSources } from "./store/source" async function maskContextMenu() { const debug = await isDebug() @@ -26,7 +26,7 @@ async function maskContextMenu() { } function CompetitiveCompanion() { - const addSource = useAddSource() + const addSources = useAddSources() useCompetitiveCompanion((p) => { let title = p.name let source: Source = { @@ -43,7 +43,7 @@ function CompetitiveCompanion() { testcases: p.tests, }, } - addSource(title, source) + addSources([{ title, source }]) }) return null } diff --git a/src/pages/Main/editor-tabpane.tsx b/src/pages/Main/editor-tabpane.tsx index b0cbf49..d722f89 100644 --- a/src/pages/Main/editor-tabpane.tsx +++ b/src/pages/Main/editor-tabpane.tsx @@ -4,7 +4,7 @@ import useReadAtom from "@/hooks/useReadAtom" import { LanguageMode } from "@/lib/ipc" import { keymapExtensionAtom } from "@/store/setting/keymap" import { clangdPathAtom, pyrightsPathAtom } from "@/store/setting/setup" -import { SourceHeader, activeIdAtom, sourceCodeChangedAtom, sourceStoreAtom } from "@/store/source" +import { SourceHeader, activeIdAtom, sourceStoreAtom } from "@/store/source" import { Extension } from "@codemirror/state" import clsx from "clsx" import { PrimitiveAtom, atom, useAtomValue } from "jotai" @@ -23,10 +23,6 @@ export default function EditorTabPanel(props: EditorProps) { () => focusAtom(sourceStoreAtom, (optic) => optic.prop(header.id).prop("code").prop("language")), [header.id], ) - const sourceChangedStatusAtom = useMemo( - () => focusAtom(sourceCodeChangedAtom, (optic) => optic.prop(header.id)), - [header.id], - ) const sourceCodeLanguage = useAtomValue(sourceCodeLanguageAtom) const readClangdPath = useReadAtom(clangdPathAtom) @@ -46,7 +42,8 @@ export default function EditorTabPanel(props: EditorProps) { hidden: active != header.id, })} sourceAtom={sourceAtom} - changedStatusAtom={sourceChangedStatusAtom} + id={header.id} + title={header.title} keymapAtom={keymapExtensionAtom} lspAtom={lspExtensionAtom} /> diff --git a/src/pages/Main/event/index.tsx b/src/pages/Main/event/index.tsx new file mode 100644 index 0000000..ee73407 --- /dev/null +++ b/src/pages/Main/event/index.tsx @@ -0,0 +1,11 @@ +import MenuEventReceiver from "./menu-event" +import StatusRecover from "./status-recover" + +export default function MainEventRegister() { + return ( + <> + + + + ) +} diff --git a/src/pages/Main/menu-event.tsx b/src/pages/Main/event/menu-event.tsx similarity index 67% rename from src/pages/Main/menu-event.tsx rename to src/pages/Main/event/menu-event.tsx index 8a9709b..6fc9e81 100644 --- a/src/pages/Main/menu-event.tsx +++ b/src/pages/Main/event/menu-event.tsx @@ -1,49 +1,33 @@ -import { useMitt } from "@/hooks/useMitt" +import { emit, useMitt } from "@/hooks/useMitt" import useReadAtom from "@/hooks/useReadAtom" import { openProblem, saveProblem } from "@/lib/fs/problem" import { LanguageMode } from "@/lib/ipc" import { defaultLanguageAtom } from "@/store/setting/setup" -import { - activeIdAtom, - counterAtom, - emptySource, - sourceIndexAtomAtoms, - sourceIndexAtoms, - sourceStoreAtom, - useAddSource, -} from "@/store/source" +import { activeIdAtom, emptySource, sourceIndexAtoms, sourceStoreAtom, useAddSources } from "@/store/source" import { dialog } from "@tauri-apps/api" -import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { useAtomValue } from "jotai" import { useImmerAtom } from "jotai-immer" +import { crc16 } from "crc" export default function MenuEventReceiver() { - const [counter, incCounter] = useAtom(counterAtom) - const dispatchSourceIndex = useSetAtom(sourceIndexAtomAtoms) const [sourceCodeStore, setSourceCodeStore] = useImmerAtom(sourceStoreAtom) const readActiveId = useReadAtom(activeIdAtom) const readSourceIndex = useReadAtom(sourceIndexAtoms) - const addSource = useAddSource() + const addSources = useAddSources() const defaultLanguage = useAtomValue(defaultLanguageAtom) useMitt("fileMenu", async (event) => { if (event == "new") { - addSource("Unamed", emptySource(defaultLanguage!)) + addSources([ + { + title: "Unamed", + source: emptySource(defaultLanguage!), + }, + ]) } else if (event == "open") { - const problems = await openProblem() - for (let i = 0; i < problems.length; i++) { - setSourceCodeStore((store) => { - store[counter + i] = problems[i][1] - }) - dispatchSourceIndex({ - type: "insert", - value: { - id: counter + i, - title: problems[i][0], - }, - }) - } - incCounter(problems.length) + const problems = (await openProblem()).map(([title, source]) => ({ title, source })) + addSources(problems) } else if (event == "save" || event == "saveAs") { const id = readActiveId() console.log(id) @@ -76,9 +60,12 @@ export default function MenuEventReceiver() { if (filepath == null) return setSourceCodeStore((prev) => { prev[id].path = filepath + prev[id].code.savedCrc = crc16(prev[id].code.source) }) await saveProblem(source, title, filepath) + } + emit('cache', -1) }) return null diff --git a/src/pages/Main/event/status-recover.tsx b/src/pages/Main/event/status-recover.tsx new file mode 100644 index 0000000..84f0c15 --- /dev/null +++ b/src/pages/Main/event/status-recover.tsx @@ -0,0 +1,23 @@ +import { useAddSources } from "@/store/source" +import { useEffect, useRef } from "react" +import * as cache from "@/lib/fs/cache" +import * as log from "tauri-plugin-log-api" + +export default function StatusRecover() { + const addSources = useAddSources() + const recovered = useRef(false) + + useEffect(() => { + log.info("recover status from cache") + if (recovered.current) return + recovered.current = true + ;(async () => { + const data = (await cache.recoverAllCache()).map(([title, source]) => ({ title, source })) + addSources(data) + await cache.dropAll() + await Promise.all(data.map((d, id) => cache.updateCache(id, d.title, d.source))) + })() + }, []) + + return null +} diff --git a/src/pages/Main/index.tsx b/src/pages/Main/index.tsx index aa27f16..71342d1 100644 --- a/src/pages/Main/index.tsx +++ b/src/pages/Main/index.tsx @@ -1,22 +1,21 @@ import PrimarySide from "./sidebar" import Tabbar from "./tabbar" -import { useAtom, useAtomValue } from "jotai" - import clsx from "clsx" import PrimaryPanel from "./sidebar-panel" -import { primaryPanelShowAtom, statusBarShowAtom } from "@/store/ui" import StatusBar from "@/components/statusbar" -import { useZoom } from "@/hooks/useZoom" -import { sourceIndexAtomAtoms, sourceIndexAtoms } from "@/store/source" import EditorTabPanel from "./editor-tabpane" import Runner from "@/components/runner" -import MenuEventReceiver from "./menu-event" +import MainEventRegister from "./event" +import { useAtom, useAtomValue } from "jotai" +import { primaryPanelShowAtom, statusBarShowAtom } from "@/store/ui" +import { useZoom } from "@/hooks/useZoom" +import { sourceIndexAtomAtoms, sourceIndexAtoms } from "@/store/source" import { hostnameAtom, setupDeviceAtom } from "@/store/setting/setup" import { useNavigate } from "react-router-dom" -import * as log from "tauri-plugin-log-api" import { useEffect } from "react" import { zip } from "lodash" import { motion } from "framer-motion" +import * as log from "tauri-plugin-log-api" export default function Main() { useZoom() @@ -38,7 +37,7 @@ export default function Main() { return ( - +
{ let delta = mouseWheel - lastWheel.current lastWheel.current = mouseWheel @@ -67,7 +65,6 @@ export default function Tabbar({ className }: { className: string }) { }, [ulHover, mouseWheel]) const [sourceIndexAtoms, patchSourceIndexAtoms] = useAtom(sourceIndexAtomAtoms) - const addSource = useAddSource() return ( <> @@ -78,16 +75,16 @@ export default function Tabbar({ className }: { className: string }) { key={index} className="h-full" atom={atom} - onRemove={() => patchSourceIndexAtoms({ type: "remove", atom })} + onRemove={(id) => { + cache.dropCache(id) + patchSourceIndexAtoms({ type: "remove", atom }) + }} moveAtom={(from, to) => patchSourceIndexAtoms({ type: "move", atom: from, before: to })} /> ))}
  • -
  • @@ -105,15 +102,14 @@ function Bar({ className?: string atom: PrimitiveAtom moveAtom: (fromAtom: PrimitiveAtom, toId: PrimitiveAtom) => void - onRemove: () => void + onRemove: (id: number) => void }) { const ref = useRef(null) const [content, setContent] = useAtom(atom) const [activeId, setActiveId] = useAtom(activeIdAtom) - const sourceChangedAtom = useMemo(() => focusAtom(sourceCodeChangedAtom, (optic) => optic.prop(activeId)), [activeId]) - const sourceChange = useAtomValue(sourceChangedAtom) + const sourceChange = useAtomValue(sourceCodeChangedAtom)[activeId] const currentLanguageAtom = useMemo( () => focusAtom(sourceStoreAtom, (optic) => optic.prop(content.id).prop("code").prop("language")), @@ -147,16 +143,16 @@ function Bar({ async function confirmRemove() { if (sourceChange) { - const confirm = !await dialog.ask(`${content.title} was changed, close file without saving?`, { + const confirm = !(await dialog.ask(`${content.title} was changed, close file without saving?`, { type: "info", okLabel: "No", - cancelLabel: "Close" - }) + cancelLabel: "Close", + })) if (!confirm) { return } } - onRemove() + onRemove(content.id) } return ( diff --git a/src/store/source.ts b/src/store/source.ts index 93987ec..e996258 100644 --- a/src/store/source.ts +++ b/src/store/source.ts @@ -3,6 +3,7 @@ import { Test } from "./testcase" import { atom, useAtom, useSetAtom } from "jotai" import { atomWithReducer, splitAtom } from "jotai/utils" import { useImmerAtom } from "jotai-immer" +import { crc16 } from "crc" export type SourceHeader = { id: number @@ -34,6 +35,7 @@ export function emptySource(language: LanguageMode): Source { export type SourceCode = { language: LanguageMode + savedCrc?: number source: string } @@ -76,31 +78,41 @@ export const sourceIndexAtomAtoms = splitAtom(sourceIndexAtoms) sourceIndexAtoms.debugLabel = "source.indexSplit" export const sourceStoreAtom = atom({}) -sourceIndexAtoms.debugLabel = "source.store" - -export const sourceCodeChangedAtom = atom({}) +sourceStoreAtom.debugLabel = "source.store" + +export const sourceCodeChangedAtom = atom((get) => { + const store = get(sourceStoreAtom) + let status: SourceChangedStatus = {} + for (const k of Object.keys(store)) { + let id = parseInt(k) + if(store[id].code.savedCrc == undefined){ + status[id] = store[id].code.source.trim().length != 0 + }else{ + status[id] = store[id].code.savedCrc != crc16(store[id].code.source) + } + } + return status +}) -export function useAddSource() { +export function useAddSources() { const [, setSrc] = useImmerAtom(sourceStoreAtom) - const [, setChange] = useImmerAtom(sourceCodeChangedAtom) const [counter, incCounter] = useAtom(counterAtom) const dispatch = useSetAtom(sourceIndexAtomAtoms) - return (title: string, source: Source) => { - setSrc((prev) => { - prev[counter] = source - }) - setChange((prev)=>{ - prev[counter] = false - }) - - dispatch({ - type: "insert", - value: { - id: counter, - title, - }, - }) - incCounter() + return (sources: { title: string; source: Source }[]) => { + for (let i = 0; i < sources.length; i++) { + let cnt = counter + i + setSrc((prev) => { + prev[cnt] = sources[i].source + }) + dispatch({ + type: "insert", + value: { + id: cnt, + title: sources[i].title, + }, + }) + } + incCounter(sources.length) } }