| 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 |