Skip to content

Commit 04d3ab2

Browse files
committed
feat: project backup mostly working
1 parent 72b1c2e commit 04d3ab2

File tree

1 file changed

+135
-56
lines changed

1 file changed

+135
-56
lines changed

src/extensions/default/NavigationAndHistory/FileRecovery.js

Lines changed: 135 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@ define(function (require, exports, module) {
3434
const sessionRestoreDir = FileSystem.getDirectoryForPath(
3535
path.normalize(NativeApp.getApplicationSupportDirectory() + "/sessionRestore"));
3636

37-
let trackingProjectRoot = null,
38-
trackingRestoreRoot = null,
39-
trackedProjectFilesMap = {},
40-
trackedFilesChangeTimestamps = {};
37+
const trackedProjects = {};
4138

4239
function simpleHash(str) {
4340
let hash = 0;
@@ -55,7 +52,7 @@ define(function (require, exports, module) {
5552
return new Promise((resolve, reject)=>{
5653
dir.create(function (err) {
5754
if (err && err !== FileSystemError.ALREADY_EXISTS) {
58-
console.error("Error creating project crash restore folder " + dir.fullPath, err);
55+
console.error("[recovery] Error creating project crash restore folder " + dir.fullPath, err);
5956
reject(err);
6057
}
6158
resolve();
@@ -74,42 +71,114 @@ define(function (require, exports, module) {
7471
});
7572
}
7673

77-
function setupProjectRestoreRoot(projectPath) {
78-
const baseName = path.basename(projectPath);
79-
let restoreRootPath = path.normalize(`${sessionRestoreDir.fullPath}/${baseName}_${simpleHash(projectPath)}`);
80-
trackingRestoreRoot = FileSystem.getDirectoryForPath(restoreRootPath);
81-
createDir(trackingRestoreRoot);
74+
function silentlyRemoveDirectory(dir) {
75+
return new Promise((resolve)=>{
76+
dir.unlink((err)=>{
77+
if(err) {
78+
console.error(err);
79+
}
80+
resolve();
81+
});
82+
});
8283
}
8384

84-
function getRestoreFilePath(projectFilePath) {
85-
if(ProjectManager.isWithinProject(projectFilePath)) {
86-
return path.normalize(
87-
`${trackingRestoreRoot.fullPath}/${ProjectManager.getProjectRelativePath(projectFilePath)}`);
85+
function getProjectRestoreRoot(projectPath) {
86+
const baseName = path.basename(projectPath),
87+
restoreRootPath = path.normalize(`${sessionRestoreDir.fullPath}/${baseName}_${simpleHash(projectPath)}`);
88+
return FileSystem.getDirectoryForPath(restoreRootPath);
89+
}
90+
91+
function getRestoreFilePath(projectFilePath, projectRootPath) {
92+
if(!projectFilePath.startsWith(projectRootPath) || !trackedProjects[projectRootPath]){
93+
console.error(`[recovery] cannot backed up as ${projectRootPath} is not in project ${projectRootPath}`);
94+
return null;
8895
}
89-
return null;
96+
let pathWithinProject = projectFilePath.replace(projectRootPath, "");
97+
let restoreRoot = trackedProjects[projectRootPath].restoreRoot;
98+
return path.normalize(`${restoreRoot.fullPath}/${pathWithinProject}`);
9099
}
91100

92101
// try not to use this
93-
function getProjectFilePath(restoreFilePath) {
94-
if(!restoreFilePath.startsWith(trackingRestoreRoot.fullPath)){
102+
function getProjectFilePath(restoreFilePath, projectRootPath) {
103+
const project = trackedProjects[projectRootPath];
104+
if(!project || !restoreFilePath.startsWith(project.restoreRoot.fullPath)){
95105
return null;
96106
}
97-
// Eg. trackingRestoreRoot = "/fs/app/sessionRestore/default project_1944444020/"
98-
// and restoreProjectRelativePath = "/fs/app/sessionRestore/default project_1944444020/default project/a.html"
99-
let restoreProjectRelativePath = restoreFilePath.replace(trackingRestoreRoot.fullPath, "");
100-
// Eg. default project/a.html
101-
let restoreProjectName = restoreProjectRelativePath.split("/")[0], // Eg. default project
102-
trackingProjectName = path.basename(trackingProjectRoot.fullPath); // default project
103-
if(trackingProjectName !== restoreProjectName){
104-
return null;
107+
108+
let filePathInProject = restoreFilePath.replace(project.restoreRoot.fullPath, "");
109+
return path.normalize(`${projectRootPath}/${filePathInProject}`);
110+
}
111+
112+
/**
113+
* the restore folder may have empty folders as files get deleted according to backup algorithm. This fn will
114+
* ensure that there are no empty folders and restore folder exists
115+
* @param folder
116+
* @return {Promise<void>}
117+
*/
118+
async function ensureFolderIsClean(folder) {
119+
await createDir(folder);
120+
await folder.unlinkEmptyDirectoryAsync();
121+
await createDir(folder);
122+
}
123+
124+
async function loadLastBackedUpFileContents(projectRootPath) {
125+
const project = trackedProjects[projectRootPath];
126+
if(!project){
127+
console.error("[recovery] Cannot load backup, no tracking info of project " + projectRootPath);
128+
return;
105129
}
106-
let filePathInProject = restoreProjectRelativePath.replace(`${restoreProjectName}/`, ""); // a.html
107-
return path.normalize(`${trackingProjectRoot.fullPath}/${filePathInProject}`);
130+
const currentProjectLoadCount = project.projectLoadCount;
131+
let restoreFolder = project.restoreRoot;
132+
await ensureFolderIsClean(restoreFolder);
133+
let allEntries = await FileSystem.getAllDirectoryContents(restoreFolder);
134+
for(let entry of allEntries){
135+
if(entry.isDirectory){
136+
continue;
137+
}
138+
let text = await jsPromise(FileUtils.readAsText(entry));
139+
let projectFilePath = getProjectFilePath(entry.fullPath, projectRootPath);
140+
if(currentProjectLoadCount !== project.projectLoadCount){
141+
// this means that while we were tying to load a project backup, the user switched to another project
142+
// and then switched back to this project, all before the first backup load was complete. so
143+
// we just return without doing anything here. This function will be eventually called on projectOpened
144+
// event handler.
145+
return;
146+
}
147+
project.lastBackedUpFileContents[projectFilePath] = text;
148+
console.log(text);
149+
}
150+
project.lastBackedupLoadInProgress = false;
108151
}
109152

110153
function projectOpened(_event, projectRoot) {
111-
trackingProjectRoot = projectRoot;
112-
setupProjectRestoreRoot(trackingProjectRoot.fullPath);
154+
if(projectRoot.fullPath === '/') {
155+
console.error("[recovery] Backups will not be done for root folder `/`");
156+
return;
157+
}
158+
if(trackedProjects[projectRoot.fullPath]){
159+
trackedProjects[projectRoot.fullPath].projectLoadCount++;
160+
trackedProjects[projectRoot.fullPath].lastBackedUpFileContents = {};
161+
trackedProjects[projectRoot.fullPath].firstEditHandled = false;
162+
trackedProjects[projectRoot.fullPath].lastBackedupLoadInProgress = true;
163+
trackedProjects[projectRoot.fullPath].trackedFileUpdateTimestamps = {};
164+
trackedProjects[projectRoot.fullPath].trackedFileContents = {};
165+
loadLastBackedUpFileContents(projectRoot.fullPath);
166+
// todo race condition here frequent switch between projects
167+
return;
168+
}
169+
trackedProjects[projectRoot.fullPath] = {
170+
projectLoadCount: 0, // we use this to prevent race conditions on frequent project switch before all
171+
// project backup files are loaded.
172+
projectRoot: projectRoot,
173+
restoreRoot: getProjectRestoreRoot(projectRoot.fullPath),
174+
lastBackedUpFileContents: {},
175+
firstEditHandled: false, // after a project is loaded, has the first edit by user on any file been handled?
176+
lastBackedupLoadInProgress: true, // while the backup is loading, we need to prevent write over the existing
177+
// backup with backup info of the current session
178+
trackedFileUpdateTimestamps: {},
179+
trackedFileContents: {}
180+
};
181+
loadLastBackedUpFileContents(projectRoot.fullPath);
113182
}
114183

115184
async function writeFileIgnoreFailure(filePath, contents) {
@@ -124,71 +193,81 @@ define(function (require, exports, module) {
124193
}
125194
}
126195

127-
async function backupChangedDocs(changedDocs) {
128-
for(let doc of changedDocs){
129-
let restorePath = getRestoreFilePath(doc.file.fullPath);
130-
await writeFileIgnoreFailure(restorePath, doc.getText());
131-
trackedFilesChangeTimestamps[doc.file.fullPath] = doc.lastChangeTimestamp;
132-
trackedProjectFilesMap[doc.file.fullPath] = restorePath;
196+
async function backupChangedDocs(projectRoot) {
197+
const project = trackedProjects[projectRoot.fullPath];
198+
let trackedFilePaths = Object.keys(project.trackedFileContents);
199+
for(let trackedFilePath of trackedFilePaths){
200+
const restorePath = getRestoreFilePath(trackedFilePath, projectRoot.fullPath);
201+
const content = project.trackedFileContents[trackedFilePath];
202+
await writeFileIgnoreFailure(restorePath, content);
203+
delete project.trackedFileContents[trackedFilePath];
133204
}
134205
}
135206

136-
async function cleanupUntrackedFiles(docPathsToTrack) {
137-
let allTrackingPaths = Object.keys(trackedProjectFilesMap);
207+
async function cleanupUntrackedFiles(docPathsToTrack, projectRoot) {
208+
const project = trackedProjects[projectRoot.fullPath];
209+
let allTrackingPaths = Object.keys(project.trackedFileUpdateTimestamps);
138210
for(let trackedPath of allTrackingPaths){
139211
if(!docPathsToTrack[trackedPath]){
140-
const restoreFile = trackedProjectFilesMap[trackedPath];
212+
const restoreFile = getRestoreFilePath(trackedPath, projectRoot.fullPath);
141213
await silentlyRemoveFile(restoreFile);
142-
delete trackedProjectFilesMap[trackedPath];
143-
delete trackedFilesChangeTimestamps[trackedPath];
214+
delete project.trackedFileUpdateTimestamps[trackedPath];
144215
}
145216
}
146217
}
147218

148219
let backupInProgress = false;
149220
async function changeScanner() {
150-
if(backupInProgress || trackingProjectRoot.fullPath === "/"){
221+
let currentProjectRoot = ProjectManager.getProjectRoot();
222+
const project = trackedProjects[currentProjectRoot.fullPath];
223+
if(backupInProgress || currentProjectRoot.fullPath === "/" || !project || project.lastBackedupLoadInProgress){
151224
// trackingProjectRoot can be "/" if debug>open virtual file system menu is clicked. Don't track root fs
152225
return;
153226
}
154227
backupInProgress = true;
155228
try{
156229
// do backup
157230
const openDocs = DocumentManager.getAllOpenDocuments();
158-
let changedDocs = [], docPathsToTrack = {};
231+
let docPathsToTrack = {}, dirtyDocsExists = false;
159232
for(let doc of openDocs){
160233
if(doc && doc.isDirty){
234+
dirtyDocsExists = true;
161235
docPathsToTrack[doc.file.fullPath] = true;
162-
const lastTrackedTimestamp = trackedFilesChangeTimestamps[doc.file.fullPath];
236+
const lastTrackedTimestamp = project.trackedFileUpdateTimestamps[doc.file.fullPath];
163237
if(!lastTrackedTimestamp || lastTrackedTimestamp !== doc.lastChangeTimestamp){
164238
// Already backed up, only need to consider it again if its contents changed
165-
changedDocs.push(doc);
239+
project.trackedFileContents[doc.file.fullPath] = doc.getText();
240+
project.trackedFileUpdateTimestamps[doc.file.fullPath] = doc.lastChangeTimestamp;
166241
}
167242
}
168243
}
169-
await backupChangedDocs(changedDocs);
170-
await cleanupUntrackedFiles(docPathsToTrack);
244+
if(!project.firstEditHandled && dirtyDocsExists) {
245+
// this means that the last backup session has been fully loaded in memory and a new edit has been
246+
// done by the user. The user may not have yet clicked on the restore backup button. But as the user
247+
// made an edit, we should delete the project restore folder to start a new backup session. The user
248+
// can still restore the last backup session from the in memory `project.lastBackedUpFileContents`
249+
await silentlyRemoveDirectory(project.restoreRoot);
250+
await createDir(project.restoreRoot);
251+
await backupChangedDocs(currentProjectRoot);
252+
project.firstEditHandled = true;
253+
} else {
254+
await backupChangedDocs(currentProjectRoot);
255+
await cleanupUntrackedFiles(docPathsToTrack, currentProjectRoot);
256+
}
171257
} catch (e) {
172258
console.error(e);
173259
logger.reportError(e);
174260
}
175261
backupInProgress = false;
176262
}
177263

178-
function documentChanged(_event, doc) {
179-
let restorePath = getRestoreFilePath(doc.file.fullPath);
180-
let originalPath = getProjectFilePath(restorePath);
181-
//debugger;
182-
}
183-
184-
function documentDirtyFlagChanged(_event, doc) {
185-
//debugger;
264+
function beforeProjectClosed() {
265+
changeScanner();
186266
}
187267

188268
function init() {
189269
ProjectManager.on(ProjectManager.EVENT_AFTER_PROJECT_OPEN, projectOpened);
190-
DocumentManager.on(DocumentManager.EVENT_DOCUMENT_CHANGE, documentChanged);
191-
DocumentManager.on(DocumentManager.EVENT_DIRTY_FLAG_CHANGED, documentDirtyFlagChanged);
270+
ProjectManager.on(ProjectManager.EVENT_PROJECT_BEFORE_CLOSE, beforeProjectClosed);
192271
createDir(sessionRestoreDir);
193272
if(!window.testEnvironment){
194273
setInterval(changeScanner, BACKUP_INTERVAL_MS);

0 commit comments

Comments
 (0)