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