master
ts 349 lines 11.9 KB
Raw
1 import { dialog, fs, path, invoke, window, clipboard } from "@tauri-apps/api";
2 import { get, writable } from 'svelte/store';
3 import { tabs, addEditorTab, renameTab, closeTab, refreshTabs, closeAllTabs } from "./EditorTabList.svelte";
4 import { filetree } from "./FileTree.svelte";
5 import { watchImmediate } from "tauri-plugin-fs-watch-api";
6 import { openFileTree } from "./Sidebar.svelte";
7 import { info, trace, warn, error } from "tauri-plugin-log-api";
8 import { homeDir } from "@tauri-apps/api/path";
9 import { appSettings } from "../config/config";
10 import { closeBottomPanel } from "./Statusbar.svelte";
11 import { closeTerminal } from "./Terminal.svelte";
12
13 export async function openFile() {
14 let newPath = await dialog.open() as string;
15 if (newPath === null) return;
16 let filename = newPath.split(path.sep).pop();
17 addEditorTab(newPath, filename);
18 }
19
20 export const workspaceName = writable("Untitled Workspace");
21 export const dirToLoad = writable("");
22 export const dirLoadFail = writable(false);
23 export const workingDir = writable(await homeDir());
24
25 export async function openFolderDialog() {
26 dirLoadFail.set(false);
27 let directory = await dialog.open({directory: true}) as string;
28 if (!directory) return;
29 openFolder(directory);
30 }
31
32
33 export async function openFolder(directory) {
34 dirLoadFail.set(false);
35 info(`Opening folder in: ${directory}`, {file: "File.ts", line: 25});
36 localStorage.setItem("lastDir", directory);
37 closeBottomPanel();
38 closeTerminal();
39 closeAllTabs();
40 loadDir(directory);
41 saveToRecent(directory);
42 }
43
44
45 export async function loadDir(directory) {
46 if (!directory) {
47 warn("Directory path is null. Aborting...", {file: "File.ts", line: 30});
48 return
49 };
50 dirToLoad.set(directory.split(path.sep).pop());
51 workingDir.set(directory);
52 // if file path is not in the configured scope already, add it
53 // TODO: should configure this so it doesnt access restricted paths based on user permissions
54 await invoke("attempt_file_access", {app_handle: window, p: directory});
55 openFileTree();
56
57 const directoryName = await updateTree(directory);
58 if (!directoryName) {
59 return;
60 }
61 // load file watcher
62 await watchImmediate(
63 directory,
64 (e) => {
65 const {type} = e;
66 const updateType = Object.entries(type)[0][0];
67 updateTree(directory, updateType);
68 },
69 { recursive: true }
70 )
71
72 workspaceName.set(directoryName);
73 }
74
75 export const treeLoading = writable(false);
76 let progressTimeout = null;
77 let loadInterval = null;
78 async function updateTree(directory, updateType = "") {
79 let loadTime = 0;
80
81 clearTimeout(progressTimeout);
82 clearInterval(loadInterval);
83
84 // dont show directory loading bar for simple file changes
85 if (updateType !== "modify") {
86 treeLoading.set(true);
87 loadInterval = setInterval(() => {loadTime++}, 1000)
88 }
89
90 let tree;
91 try {
92 tree = await fs.readDir(directory, {recursive: true});
93 } catch (e) {
94 error(e);
95 }
96 if (!tree || tree === undefined) {
97 error(`Cannot load directory: ${tree}.`, {file: "File.ts", line: 73});
98 cancelDirectoryLoad("Cannot load directory");
99 return null;
100 }
101
102 if (loadTime > 100) {
103 warn("Directory load time was too long. Aborting...", {file: "File.ts", line: 79});
104 trace(`Cancelled directory load after ${loadTime} seconds. Directory: ${tree}`);
105
106 cancelDirectoryLoad("Error: Directory load timeout.");
107 return null;
108 }
109
110 let directoryName = get(dirToLoad);
111 tree = [{id: -1, name: directoryName, path: directory, children: buildTree(sortTree(tree))}];
112 filetree.set(tree);
113 clearInterval(loadInterval);
114
115 //TODO: move this to a log file
116 if (updateType !== "modify") {
117 if (loadTime < 45) {
118 trace(`Directory load time: ${loadTime < 1 ? "less than 1" : loadTime}s`);
119 }
120 }
121 id = 0;
122 treeLoading.set(false);
123 return directoryName;
124 }
125
126 // Need to add ids to each node for svelte to iterate over them properly
127 let id = 0;
128 function buildTree(tree: fs.FileEntry[]) {
129 const nodes = [];
130 for (const node of tree) {
131 const entry = {id: id, name: node.name, path: node.path, children: node.children};
132 id++;
133 if (node.children) {
134 entry.children = buildTree(sortTree(node.children));
135 }
136 nodes.push(entry);
137 }
138 return nodes;
139 }
140 function sortTree(tree: fs.FileEntry[]) {
141 const sortedTree = [];
142 let files = [];
143 for (const node of tree) {
144 if (node.children) {
145 sortedTree.push(node)
146 sortTree(node.children);
147 }
148 else {
149 files.push(node);
150 }
151 }
152 files.sort((a, b) => (a - b));
153 sortedTree.sort((a, b) => (a - b));
154 sortedTree.push(...files);
155 return sortedTree;
156 }
157
158 export function cancelDirectoryLoad(msg: string) {
159 clearInterval(loadInterval);
160
161 dirLoadFail.set(true);
162 dirToLoad.set(msg);
163 progressTimeout = setTimeout(() => {
164 treeLoading.set(false);
165 }, 5000)
166 }
167
168 export async function moveFile(source: string, dest: string, file: string) {
169 if (file === dest) {
170 return;
171 }
172 const filename = file.split(path.sep).pop();
173 if (!await dialog.confirm(`Are you sure you want to move "${filename}" from "./${source.split(path.sep).pop()}" into "./${dest.split(path.sep).pop()}?"`, {title: "Svara: Move File"})) {
174 return;
175 }
176
177 try {
178 await fs.renameFile(file, `${dest}${path.sep}${filename}`);
179 const tab = get(tabs).find(t => t.path === `${source}${path.sep}${filename}`);
180 if (tab === undefined) return;
181 tab.path = `${dest}${path.sep}${filename}`;
182 refreshTabs();
183 } catch (e) {
184 error(`Cannot move ${file} into ${dest}. Error: ${e}`, {file: "File.ts", line: 159});
185 }
186 }
187
188 export async function saveFile(saveAs = false) {
189 const tab = get(tabs).find(t => t.active && t.isfile);
190 if (!tab.path.includes(path.sep) || tab.path === "" || saveAs) {
191 let newPath = await dialog.save({defaultPath: `${tab.label}.txt`});
192 if (newPath === null) return;
193
194 tab.path = newPath;
195 tab.label = newPath.split(path.sep).pop();
196 }
197 // write changes to the file
198 await invoke("write_file", {path: tab.path, content: tab.content.getFileContent(), enc: tab.content.getEncoding(), hasBom: tab.content.hasBom()})
199 const fileType = await path.extname(tab.path);
200 tab.content.updateFileInfo({
201 "filename": tab.label,
202 "path": tab.path,
203 "fileType": fileType,
204 "encoding": tab.content.getEncoding(),
205 "hasBom": tab.content.hasBom(),
206 "language": await tab.content.getLang(fileType),
207 "readonly": false,
208 });
209 tab.setActive(tab.id);
210 updateSaveState(true);
211 }
212
213 export function updateSaveState(saved = true) {
214 const tab = get(tabs).find(t => t.active && t.isfile);
215 if (saved) {
216 tab.saved = true;
217 return; // prevents this being fired on every state check
218 }
219 else {
220 tab.saved = false;
221 }
222 tab.setActive(tab.id);
223 }
224
225 export async function openInExplorer(path: string) {
226 trace(`Opening ${path} in system explorer...`);
227 invoke("open_in_explorer",{ path: path});
228 }
229
230 export async function moveToTrash(p: string) {
231 // open dialog to choose between recycling bin and perm delete
232 if (!await dialog.ask(`Are you sure you want to delete ${p.split(path.sep).pop()}?`)) return;
233 await invoke("delete_file", {path: p, perm: false})
234 const tab = get(tabs).find(t => t.path === p);
235 if (tab === undefined) return;
236 closeTab(tab.id);
237 }
238
239 export async function createFolder(p) {
240 try {
241 await fs.createDir(p);
242 } catch (e) {
243 error(`Cannot create folder in path ${p}. Error: ${e}`, {file: "File.ts", line: 216});
244 }
245 }
246 export async function createFile(p) {
247 try {
248 await invoke("write_file", {path: p, content: "", enc: "UTF-8", hasBom: false});
249 } catch (e) {
250 error(`Cannot create file in path ${p}. Error: ${e}`, {file: "File.ts", line: 223});
251 }
252 addEditorTab(p, p.split(path.sep).pop());
253 }
254
255 export async function renameFile(filename: string, oldpath: string) {
256 let isFile = false;
257 if (filename.length === 0) {
258 warn("Cannot rename file with length 0", {file: "File.ts", line: 230});
259 return false;
260 }
261 isFile = await invoke("is_file", {path: oldpath});
262 if (isFile && filename.includes(path.sep)) {
263 warn("Cannot rename from invalid file name", {file: "File.ts", line: 234});
264 return false;
265 }
266 let newpath = oldpath.substring(0, oldpath.lastIndexOf(oldpath.split(path.sep).at(-1))) + filename;
267 try {
268 await fs.renameFile(oldpath, newpath);
269 } catch (e) {
270 error(`Cannot rename ${oldpath}. Error: ${e}`, {file: "File.ts", line: 242})
271 return false;
272 }
273 if (isFile) {
274 let tab = get(tabs).find(t => t.active && t.isfile);
275 renameTab(tab, filename, newpath);
276 return true;
277 }
278 let openTabs = get(tabs).filter(t => t.path === `${oldpath}${path.sep}${t.label}`);
279 for (const tab of openTabs) {
280 tab.path = `${newpath}${path.sep}${tab.label}`;
281 }
282 refreshTabs();
283 return true;
284 }
285
286 export async function readFile(path) {
287 let fileData = {text: "", encoding: "UTF-8", extension: "", bom: false, spaces: await appSettings.get("editor.tabSize")};
288 try {
289 fileData = await invoke("read_file", {path: path});
290 if (fileData.spaces === 0) {
291 fileData.spaces = await appSettings.get("editor.tabSize")
292 }
293 } catch (error) {
294 warn(`Can't read file content in ${path}. Setting to empty string. Error: ${error}`, {file: "Tab.ts", line: 79});
295 }
296 return fileData;
297 }
298
299 export async function pasteFile(dest) {
300 const copied = await clipboard.readText();
301 const filename = copied.split(path.sep).pop();
302 let newpath = `${dest}${path.sep}${filename}`;
303 if (!await fs.exists(copied) || await fs.exists(newpath))
304 return
305 const fileData = await readFile(copied);
306
307 if (!await dialog.confirm(`Are you sure you want to copy "${filename}" from "./${copied.split(path.sep).pop()}" into "./${dest.split(path.sep).pop()}?"`, {title: ": Move File"})) {
308 return;
309 }
310 try {
311 await invoke("write_file", {path: newpath, content: fileData.text, enc: fileData.encoding, hasBom: fileData.bom, spaces: fileData.spaces});
312 } catch (e) {
313 error(`Cannot create file in path ${dest}. Error: ${e}`, {file: "File.ts", line: 287});
314 }
315 addEditorTab(newpath, filename);
316 }
317
318 export function checkValidFileName(input: string) {
319 // refer to https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file for invalid characters
320 // also https://gist.github.com/doctaphred/d01d05291546186941e1b7ddc02034d3
321 const invalidChars = `<>:"|?*${path.sep}`;
322 const invalidKeywords = ["CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"];
323
324 // covers all non printable ascii characters (https://en.wikipedia.org/wiki/Control_character)
325 for (let i = 0; i < 32; i++) {
326 if (input.includes(String.fromCharCode(i))) return false;
327 }
328 for (const c of invalidChars) {
329 if (input.includes(c)) return false;
330 }
331 for (const keyword of invalidKeywords) {
332 if (input.includes(keyword)) return false;
333 }
334 if (input.endsWith(".")) return false;
335 if (input === "") return false;
336 return true;
337 }
338
339 export function saveToRecent(path: string) {
340 const recent = localStorage.getItem("recentFolders");
341 if (!recent) {
342 localStorage.setItem("recentFolders", JSON.stringify([path]));
343 return;
344 }
345 const recentFolders = JSON.parse(recent) || [];
346 const updatedRecentFolders = [path, ...recentFolders.filter(f => f !== path)].slice(0, 10); // save 10 recent folders max
347 localStorage.setItem("recentFolders", JSON.stringify(updatedRecentFolders));
348 }
349