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