返回 slidev
projects.ts
根目录 / packages / vscode / src / projects.ts
1 import type { LoadedSlidevData } from '@slidev/parser/fs'
2 import type { ComputedRef, EffectScope, Ref, ShallowRef } from 'reactive-vscode'
3 import type { SlidevServer } from './composables/useDevServer'
4 import type { DetectedServerState } from './composables/useServerDetector'
5 import { existsSync } from 'node:fs'
6 import { debounce, slash } from '@antfu/utils'
7 import { load } from '@slidev/parser/fs'
8 import { basename, dirname } from 'pathe'
9 import { isMatch } from 'picomatch'
10 import { computed, effectScope, extensionContext, markRaw, onScopeDispose, ref, shallowReactive, shallowRef, useDisposable, useFileSystemWatcher, useVscodeContext, watch, watchEffect } from 'reactive-vscode'
11 import { FileSystemError, Uri, window, workspace } from 'vscode'
12 import { useServerDetector } from './composables/useServerDetector'
13 import { config } from './configs'
14 import { findShallowestPath } from './utils/findShallowestPath'
15 import { logger } from './views/logger'
16
17 export interface SlidevProject {
18 readonly scope: EffectScope
19 readonly entry: string
20 readonly userRoot: string
21 readonly data: LoadedSlidevData
22 readonly port: Ref<number | null>
23 readonly server: ShallowRef<SlidevServer | null>
24 readonly detected: ComputedRef<DetectedServerState | null>
25 }
26
27 export const projects = shallowReactive(new Map<string, SlidevProject>())
28 export const slidevFiles = computed(() => [...projects.values()].flatMap(p => Object.keys(p.data.markdownFiles)))
29 export const activeEntry = ref<string | null>(null)
30 export const activeProject = computed(() => activeEntry.value ? projects.get(activeEntry.value) : undefined)
31 export const activeData = computed(() => activeProject.value?.data)
32
33 export function useProjects() {
34 useFileSystemWatcher(() => config.include, {
35 async onDidCreate(uri) {
36 const path = slash(uri.fsPath)
37 if (!isMatch(path, config.exclude))
38 await addProject(path)
39 },
40 onDidChange: false,
41 async onDidDelete(uri) {
42 removeProject(slash(uri.fsPath))
43 },
44 })
45
46 rescanProjects()
47 watch(() => [config.include, config.exclude], debounce(200, rescanProjects))
48
49 // In case all the projects are removed manually, and the user may not want to disable the extension.
50 const everHadProjects = ref(false)
51 watchEffect(() => {
52 if (projects.size > 0)
53 everHadProjects.value = true
54 })
55
56 // Save active project to workspace state
57 watchEffect(() => {
58 if (activeEntry.value)
59 extensionContext.value!.workspaceState.update('slidev:activeProject', activeEntry.value)
60 })
61
62 // Auto set active project
63 watch(() => [...projects.keys(), activeEntry.value], () => {
64 if (!activeEntry.value) {
65 const previous = extensionContext.value!.workspaceState.get('slidev:activeProject', null)
66 if (previous && projects.has(previous)) {
67 activeEntry.value = previous
68 return
69 }
70 const firstKind = findShallowestPath(
71 [...projects.keys()].filter(path => basename(path) === 'slides.md'),
72 )
73 if (firstKind) {
74 activeEntry.value = firstKind
75 return
76 }
77 const secondKind = findShallowestPath(projects.keys())
78 if (secondKind) {
79 activeEntry.value = secondKind
80 }
81 }
82 }, { immediate: true })
83
84 useVscodeContext('slidev:enabled', () => {
85 const forceEnabled = config['force-enabled']
86 const enabled = forceEnabled ?? everHadProjects.value
87 logger.info(`Slidev ${enabled ? 'enabled' : 'disabled'}.`)
88 return enabled
89 })
90 useVscodeContext('slidev:hasActiveProject', () => !!activeEntry.value)
91
92 onScopeDispose(() => {
93 for (const project of projects.values()) {
94 project.scope.stop()
95 }
96 projects.clear()
97 })
98 }
99
100 let scanningProjects = false
101 export const scannedProjects = ref(false)
102 export async function rescanProjects() {
103 if (scanningProjects)
104 return
105 scanningProjects = true
106 try {
107 const entries = new Set<string>()
108 for (const glob of config.include) {
109 (await workspace.findFiles(glob, config.exclude))
110 .forEach(file => entries.add(file.fsPath))
111 }
112 for (const entry of entries) {
113 await addProject(slash(entry))
114 }
115 for (const project of projects.values()) {
116 if (!existsSync(project.entry)) {
117 removeProject(project.entry)
118 }
119 }
120 }
121 finally {
122 scanningProjects = false
123 scannedProjects.value = true
124 }
125 }
126
127 export async function addProject(entry: string) {
128 if (projects.has(entry))
129 return projects.get(entry)!
130
131 const { getDetected } = useServerDetector()
132
133 const scope = effectScope(true)
134 const data = shallowRef<LoadedSlidevData>(await loadProject(entry))
135 const project: SlidevProject = {
136 scope,
137 entry,
138 userRoot: dirname(entry),
139 get data() {
140 return data.value
141 },
142 server: shallowRef(null),
143 port: ref(null),
144 detected: computed(() => getDetected(project)),
145 }
146
147 scope.run(() => {
148 // Handle changes. VSCode already debounces rapid changes itself.
149 let pendingReload: Promise<LoadedSlidevData> | null = null
150 useDisposable(workspace.onDidChangeTextDocument(async ({ document }) => {
151 const path = slash(document.uri.fsPath)
152 if (data.value?.watchFiles[path]) {
153 const thisReload = pendingReload = loadProject(entry)
154 const newData = await thisReload
155 if (pendingReload === thisReload) { // still the latest
156 data.value = newData
157 pendingReload = null
158 }
159 }
160 }))
161
162 useDisposable(workspace.onDidCloseTextDocument(async (document) => {
163 const path = slash(document.uri.fsPath)
164 if (path !== entry) {
165 return
166 }
167 try {
168 await workspace.fs.stat(document.uri)
169 }
170 catch (err) {
171 if (err instanceof FileSystemError && err.code === 'FileNotFound') {
172 removeProject(entry)
173 }
174 }
175 }))
176
177 onScopeDispose(() => {
178 projects.get(entry)?.server.value?.scope.stop()
179 })
180 })
181
182 projects.set(entry, project)
183 return project
184 }
185
186 export function removeProject(entry: string) {
187 const project = projects.get(entry)
188 if (!project)
189 return
190 if (activeEntry.value === entry)
191 activeEntry.value = null
192 project.scope.stop()
193 projects.delete(entry)
194 }
195
196 async function loadProject(entry: string) {
197 const userRoot = dirname(entry)
198 return markRaw(await load({ userRoot, roots: [userRoot] }, entry, async (path: string) => {
199 const document = workspace.textDocuments.find(d => slash(d.uri.fsPath) === path)
200 if (document) {
201 return document.getText()
202 }
203 const buffer = await workspace.fs.readFile(Uri.file(path))
204 return (new TextDecoder('utf-8')).decode(buffer)
205 }))
206 }
207
208 const ignoredEntries = new Set<string>()
209 export async function askAddProject(entry: string, message: string) {
210 if (projects.has(entry) || ignoredEntries.has(entry))
211 return
212 if (!workspace.getWorkspaceFolder(Uri.file(entry))) {
213 return
214 }
215 const result = await window.showInformationMessage(`${message}\nDo you want to add ${entry} as a Slidev project?`, 'Yes', 'No')
216 if (result === 'Yes') {
217 await addProject(entry)
218 }
219 else {
220 ignoredEntries.add(entry)
221 }
222 }
223
223 lines TYPESCRIPT