// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the Apache License, Version 2.0
// http://www.apache.org/licenses/LICENSE-2.0
package com.google.appinventor.client.editor;
import static com.google.appinventor.client.Ode.MESSAGES;
import com.google.appinventor.client.ErrorReporter;
import com.google.appinventor.client.Ode;
import com.google.appinventor.client.OdeAsyncCallback;
import com.google.appinventor.client.editor.youngandroid.YaBlocksEditor;
import com.google.appinventor.client.editor.youngandroid.YailGenerationException;
import com.google.appinventor.client.explorer.project.Project;
import com.google.appinventor.client.output.OdeLog;
import com.google.appinventor.client.settings.project.ProjectSettings;
import com.google.appinventor.shared.rpc.BlocksTruncatedException;
import com.google.appinventor.shared.rpc.project.FileDescriptorWithContent;
import com.google.appinventor.shared.rpc.project.ProjectRootNode;
import com.google.common.collect.Maps;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.Timer;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Manager class for opened project editors.
*
* @author lizlooney@google.com (Liz Looney)
*/
public final class EditorManager {
// Map of project IDs to open project editors
private final Map<Long, ProjectEditor> openProjectEditors;
// Timeout (in ms) after which changed content is auto-saved if the user did
// not continue typing.
// TODO(user): Make this configurable.
private static final int AUTO_SAVE_IDLE_TIMEOUT = 5000;
// Currently set to 5 seconds. Note: the GWT code as a ClosingHandler
// that will perform a save when the user closes the window.
// Timeout (in ms) after which changed content is auto-saved even if the user
// continued typing.
// TODO(user): Make this configurable.
private static final int AUTO_SAVE_FORCED_TIMEOUT = 30000;
// Fields used for saving and auto-saving.
private final Set<ProjectSettings> dirtyProjectSettings;
private final Set<FileEditor> dirtyFileEditors;
private final Timer autoSaveTimer;
private boolean autoSaveIsScheduled;
private long autoSaveRequestTime;
private class DateHolder {
long date;
long projectId;
}
/**
* Creates the editor manager.
*/
public EditorManager() {
openProjectEditors = Maps.newHashMap();
dirtyProjectSettings = new HashSet<ProjectSettings>();
dirtyFileEditors = new HashSet<FileEditor>();
autoSaveTimer = new Timer() {
@Override
public void run() {
// When the timer goes off, save all dirtyProjectSettings and
// dirtyFileEditors.
Ode.getInstance().lockScreens(true); // Lock out changes
saveDirtyEditors(new Command() {
@Override
public void execute() {
Ode.getInstance().lockScreens(false); // I/O finished, unlock
}
});
}
};
}
/**
* Opens the project editor for the given project.
* If there is an editor already open for the project, it will be returned.
* Otherwise, it will create an appropriate editor for the project.
*
* @param projectRootNode the root node of the project to open
* @return project editor for the given project
*/
public ProjectEditor openProject(ProjectRootNode projectRootNode) {
long projectId = projectRootNode.getProjectId();
ProjectEditor projectEditor = openProjectEditors.get(projectId);
if (projectEditor == null) {
// No open editor for this project yet.
// Use the ProjectEditorRegistry to get the factory and create the project editor.
ProjectEditorFactory factory = Ode.getProjectEditorRegistry().get(projectRootNode);
if (factory != null) {
projectEditor = factory.createProjectEditor(projectRootNode);
// Add the editor to the openProjectEditors map.
openProjectEditors.put(projectId, projectEditor);
// Tell the DesignToolbar about this project
Ode.getInstance().getDesignToolbar().addProject(projectId, projectRootNode.getName());
// Prepare the project before Loading into the editor.
// Components are prepared before the project is actually loaded.
// Load the project into the editor. The actual loading is asynchronous.
projectEditor.processProject();
}
}
return projectEditor;
}
/**
* Gets the open project editor of the given project ID.
*
* @param projectId the project ID
* @return the ProjectEditor of the specified project, or null
*/
public ProjectEditor getOpenProjectEditor(long projectId) {
return openProjectEditors.get(projectId);
}
/**
* Closes the file editors for the specified files, without saving.
* This is used when the files are about to be deleted.
*
* @param projectId project ID
* @param fileIds file IDs of the file editors to be closed
*/
public void closeFileEditors(long projectId, String[] fileIds) {
ProjectEditor projectEditor = openProjectEditors.get(projectId);
if (projectEditor != null) {
for (String fileId : fileIds) {
FileEditor fileEditor = projectEditor.getFileEditor(fileId);
// in case the file is not open in an editor (possible?) check
// the FileEditors for null.
if (fileEditor != null) {
dirtyFileEditors.remove(fileEditor);
}
}
projectEditor.closeFileEditors(fileIds);
}
}
/**
* Closes the project editor for a particular project, without saving.
* Does not actually remove the editor from the ViewerBox.
* This is used when the project is about to be deleted.
*
* @param projectId ID of project whose editor is to be closed
*/
public void closeProjectEditor(long projectId) {
// TODO(lizlooney) - investigate whether the ProjectEditor and all its FileEditors stay in
// memory even after we've removed them.
Project project = Ode.getInstance().getProjectManager().getProject(projectId);
ProjectSettings projectSettings = project.getSettings();
dirtyProjectSettings.remove(projectSettings);
openProjectEditors.remove(projectId);
}
/**
* Schedules auto-save of the given project settings.
* This method can be called often, as the user is modifying project settings.
*
* @param projectSettings the project settings for which to schedule auto-save
*/
public void scheduleAutoSave(ProjectSettings projectSettings) {
// Add the project settings to the dirtyProjectSettings list.
dirtyProjectSettings.add(projectSettings);
scheduleAutoSaveTimer();
}
/**
* Schedules auto-save of the given file editor.
* This method can be called often, as the user is modifying a file.
*
* @param fileEditor the file editor for which to schedule auto-save
*/
public void scheduleAutoSave(FileEditor fileEditor) {
// Add the file editor to the dirtyFileEditors list.
if (!fileEditor.isDamaged()) { // Don't save damaged files
dirtyFileEditors.add(fileEditor);
} else {
OdeLog.log("Not saving blocks for " + fileEditor.getFileId() + " because it is damaged.");
}
scheduleAutoSaveTimer();
}
/**
* Check whether there is an open project editor.
*
* @return true if at least one project is open (or in the process of opening), otherwise false
*/
public boolean hasOpenEditor() {
return openProjectEditors.size() > 0;
}
/**
* Schedules the auto-save timer.
*/
private void scheduleAutoSaveTimer() {
if (autoSaveIsScheduled) {
// The auto-save timer is already scheduled.
// The user is making multiple changes and, in general, we want to wait until they are idle
// before saving. However, we don't want to delay the auto-save forever.
// If the time that the auto-save was first requested wasn't too long ago, cancel and
// reschedule the timer. Otherwise, leave the scheduled timer alone.
if (System.currentTimeMillis() - autoSaveRequestTime < AUTO_SAVE_FORCED_TIMEOUT) {
autoSaveTimer.cancel();
autoSaveTimer.schedule(AUTO_SAVE_IDLE_TIMEOUT);
}
} else {
// The auto-save timer is not already scheduled.
// Schedule it now and set autoSaveRequestTime.
autoSaveTimer.schedule(AUTO_SAVE_IDLE_TIMEOUT);
autoSaveRequestTime = System.currentTimeMillis();
autoSaveIsScheduled = true;
}
}
/**
* Saves all modified files and project settings and calls the afterSaving
* command after they have all been saved successfully.
*
* If any errors occur while saving, the afterSaving command will not be
* executed.
* If nothing needs to be saved, the afterSavingFiles command is called
* immediately, not asynchronously.
*
* @param afterSaving optional command to be executed after project
* settings and file editors are saved successfully
*/
public void saveDirtyEditors(final Command afterSaving) {
// Note, We don't do any saving if we are in read only mode
if (Ode.getInstance().isReadOnly()) {
afterSaving.execute();
return;
}
// Collect the files that need to be saved.
List<FileDescriptorWithContent> filesToSave = new ArrayList<FileDescriptorWithContent>();
for (FileEditor fileEditor : dirtyFileEditors) {
FileDescriptorWithContent fileContent = new FileDescriptorWithContent(
fileEditor.getProjectId(), fileEditor.getFileId(), fileEditor.getRawFileContent());
filesToSave.add(fileContent);
}
dirtyFileEditors.clear();
// Collect the project settings that need to be saved.
List<ProjectSettings> projectSettingsToSave = new ArrayList<ProjectSettings>();
projectSettingsToSave.addAll(dirtyProjectSettings);
dirtyProjectSettings.clear();
autoSaveTimer.cancel();
autoSaveIsScheduled = false;
// Keep count as each save operation finishes so we can set the projects' modified date and
// call the afterSaving command after everything has been saved.
// Each project settings is saved as a separate operation, but all files are saved as a single
// save operation. So the initial value of pendingSaveOperations is the size of
// projectSettingsToSave plus 1.
final AtomicInteger pendingSaveOperations = new AtomicInteger(projectSettingsToSave.size() + 1);
final DateHolder dateHolder = new DateHolder();
Command callAfterSavingCommand = new Command() {
@Override
public void execute() {
if (pendingSaveOperations.decrementAndGet() == 0) {
// Execute the afterSaving command if one was given.
if (afterSaving != null) {
afterSaving.execute();
}
// Set the project modification date to the returned date
// for one of the saved files (it doens't really matter which one).
if ((dateHolder.date != 0) && (dateHolder.projectId != 0)) { // We have a date back from the server
Ode.getInstance().updateModificationDate(dateHolder.projectId, dateHolder.date);
}
}
}
};
// Save all files at once (asynchronously).
saveMultipleFilesAtOnce(filesToSave, callAfterSavingCommand, dateHolder);
// Save project settings one at a time (asynchronously).
for (ProjectSettings projectSettings : projectSettingsToSave) {
projectSettings.saveSettings(callAfterSavingCommand);
}
}
/**
* For each block editor (screen) in the current project, generate and save yail code for the
* blocks.
*
* @param successCommand optional command to be executed if yail generation and saving succeeds.
* @param failureCommand optional command to be executed if yail generation and saving fails.
*/
public void generateYailForBlocksEditors(final Command successCommand,
final Command failureCommand) {
List<FileDescriptorWithContent> yailFiles = new ArrayList<FileDescriptorWithContent>();
long currentProjectId = Ode.getInstance().getCurrentYoungAndroidProjectId();
for (long projectId : openProjectEditors.keySet()) {
if (projectId == currentProjectId) {
// Generate yail for each blocks editor in this project and add it to the list of
// yail files. If an error occurs we stop the generation process, report the error,
// and return without executing nextCommand.
ProjectEditor projectEditor = openProjectEditors.get(projectId);
for (FileEditor fileEditor : projectEditor.getOpenFileEditors()) {
if (fileEditor instanceof YaBlocksEditor) {
YaBlocksEditor yaBlocksEditor = (YaBlocksEditor) fileEditor;
try {
yailFiles.add(yaBlocksEditor.getYail());
} catch (YailGenerationException e) {
ErrorReporter.reportInfo(MESSAGES.yailGenerationError(e.getFormName(),
e.getMessage()));
if (failureCommand != null) {
failureCommand.execute();
}
return;
}
}
}
break;
}
}
Ode.getInstance().getProjectService().save(Ode.getInstance().getSessionId(),
yailFiles,
new OdeAsyncCallback<Long>(MESSAGES.saveErrorMultipleFiles()) {
@Override
public void onSuccess(Long date) {
if (successCommand != null) {
successCommand.execute();
}
}
@Override
public void onFailure(Throwable caught) {
super.onFailure(caught);
if (failureCommand != null) {
failureCommand.execute();
}
}
});
}
/**
* This code used to send the contents of all changed files to the server
* in the same RPC transaction. However we are now sending them separately
* so that we can have more fine grained control over handling errors that
* happen only on one file. In particular, we need to handle the case where
* a trivial blocks workspace is attempting to be written over a non-trival
* file.
*
* If any unhandled errors occur while saving, the afterSavingFiles
* command will not be executed. If filesWithContent is empty, the
* afterSavingFiles command is called immediately, not
* asynchronously.
*
* @param filesWithContent the files that need to be saved
* @param afterSavingFiles optional command to be executed after file
* editors are saved.
*/
private void saveMultipleFilesAtOnce(
final List<FileDescriptorWithContent> filesWithContent, final Command afterSavingFiles, final DateHolder dateHolder) {
if (filesWithContent.isEmpty()) {
// No files needed saving.
// Execute the afterSavingFiles command if one was given.
if (afterSavingFiles != null) {
afterSavingFiles.execute();
}
} else {
for (FileDescriptorWithContent fileDescriptor : filesWithContent ) {
final long projectId = fileDescriptor.getProjectId();
final String fileId = fileDescriptor.getFileId();
final String content = fileDescriptor.getContent();
Ode.getInstance().getProjectService().save2(Ode.getInstance().getSessionId(),
projectId, fileId, false, content, new OdeAsyncCallback<Long>(MESSAGES.saveErrorMultipleFiles()) {
@Override
public void onSuccess(Long date) {
if (dateHolder.date != 0) {
// This sets the project modification time to that of one of
// the successful file saves. It doesn't really matter which
// file date we use, they will all be close. However it is important
// to use some files date because that will be based on the server's
// time. If we used the local clients time, then we may be off if the
// client's computer's time isn't set correctly.
dateHolder.date = date;
dateHolder.projectId = projectId;
}
if (afterSavingFiles != null) {
afterSavingFiles.execute();
}
}
@Override
public void onFailure(Throwable caught) {
// Here is where we handle BlocksTruncatedException
if (caught instanceof BlocksTruncatedException) {
Ode.getInstance().blocksTruncatedDialog(projectId, fileId, content, this);
} else {
super.onFailure(caught);
}
}
});
}
}
}
}