feat(developer): Open files in per-project instances

Refactor the file-opening code so it can be used by Keyman Developer UI
as well as command-line, and then add support for opening files in
per-project instances from File|Open and friends.

Also moves the initialization code out of tike.dpr into
Keyman.Developer.System.Main.pas, which makes it easier to maintain and
read.
This commit is contained in:
Marc Durdin 2023-12-04 08:47:36 +07:00
parent 915510b845
commit ff5a366c45
10 changed files with 426 additions and 223 deletions

View file

@ -334,11 +334,8 @@ begin
end;
procedure TmodActionsMain.actFileOpenAccept(Sender: TObject);
var
i: Integer;
begin
for i := 0 to actFileOpen.Dialog.Files.Count - 1 do
frmKeymanDeveloper.OpenFileInProject(actFileOpen.Dialog.Files[i]);
frmKeymanDeveloper.OpenFilesInProject(actFileOpen.Dialog.Files.ToStringArray);
end;
procedure TmodActionsMain.actFileOpenUpdate(Sender: TObject);

View file

@ -15,8 +15,8 @@ uses
type
IDragDrop = interface
function DropAllowed(const FileNames: array of string): Boolean;
procedure Drop(const FileNames: array of string);
function DropAllowed(const FileNames: TArray<string>): Boolean;
procedure Drop(const FileNames: TArray<string>);
end;
TDropTarget = class(TObject, IInterface, IDropTarget)

View file

@ -0,0 +1,183 @@
unit Keyman.Developer.System.LaunchProjects;
interface
uses
System.Classes,
System.Generics.Collections,
System.SysUtils,
Keyman.Developer.System.TikeMultiProcess;
type
TLaunchProjectStatus = (lpsWaiting, lpsCurrentInstance, lpsOtherInstance, lpsNewInstance, lpsError);
TLaunchProject = class
private
FProjectFilename: string;
FFilenames: TStringList;
FStatus: TLaunchProjectStatus;
public
constructor Create(const AProjectFilename: string);
destructor Destroy; override;
function LaunchAsNewInstance: Boolean;
function PassToRunningProcess(AProcesses: TTikeProcessList): Boolean;
property ProjectFilename: string read FProjectFilename;
property Filenames: TStringList read FFilenames;
property Status: TLaunchProjectStatus read FStatus;
end;
TLaunchProjects = class(TObjectList<TLaunchProject>)
private
FReserveStartupProject: Boolean;
function GetStartupProject: TLaunchProject;
public
constructor Create(AReserveStartupProject: Boolean);
procedure GroupFilenamesIntoProjects(const Filenames: TStringList);
function LaunchAll(AProcesses: TTikeProcessList): Boolean;
function Find(const ProjectFilename: string): TLaunchProject;
property ReserveStartupProject: Boolean read FReserveStartupProject;
property StartupProject: TLaunchProject read GetStartupProject;
end;
implementation
uses
Winapi.Windows,
Keyman.Developer.System.ProjectOwningFile,
utilexecute;
{ TLaunchProject }
constructor TLaunchProject.Create(const AProjectFilename: string);
begin
inherited Create;
FProjectFilename := AProjectFilename;
FFilenames := TStringList.Create;
FStatus := lpsWaiting;
end;
destructor TLaunchProject.Destroy;
begin
FFilenames.Free;
inherited Destroy;
end;
function TLaunchProject.LaunchAsNewInstance: Boolean;
var
filename, cmdline: string;
begin
cmdline := '"'+ParamStr(0)+'" --sub-process "'+Self.ProjectFilename+'"';
for filename in Self.Filenames do
cmdline := cmdline + ' "'+filename+'"';
Result := TUtilExecute.Execute(cmdline, GetCurrentDir, SW_SHOWNORMAL);
if Result
then FStatus := lpsNewInstance
else FStatus := lpsError;
end;
function TLaunchProject.PassToRunningProcess(AProcesses: TTikeProcessList): Boolean;
var
tp: TTikeProcess;
filename: string;
begin
for tp in AProcesses do
begin
if tp.OwnsProject(Self.ProjectFilename) then
begin
FStatus := lpsOtherInstance;
if Self.Filenames.Count = 0 then
begin
// Ensure that the project file tab is opened
tp.OpenFile(Self.ProjectFilename);
end;
for filename in Self.Filenames do
begin
tp.OpenFile(filename);
end;
Exit(True);
end;
end;
Result := False;
end;
{ TLaunchProjects }
constructor TLaunchProjects.Create(AReserveStartupProject: Boolean);
begin
inherited Create(True);
FReserveStartupProject := AReserveStartupProject;
end;
function TLaunchProjects.Find(const ProjectFilename: string): TLaunchProject;
begin
for Result in Self do
begin
if Result.ProjectFilename = ProjectFilename then
begin
Exit;
end;
end;
Result := nil;
end;
function TLaunchProjects.GetStartupProject: TLaunchProject;
begin
for Result in Self do
if Result.Status = lpsCurrentInstance then
Exit;
Result := nil;
end;
procedure TLaunchProjects.GroupFilenamesIntoProjects(const Filenames: TStringList);
var
projectFilename, filename: string;
p: TLaunchProject;
begin
for filename in filenames do
begin
projectFilename := FindOwnerProjectForFile(filename);
p := Self.Find(projectFilename);
if p = nil then
begin
p := TLaunchProject.Create(projectFilename);
Self.Add(p);
end;
p.Filenames.Add(filename);
end;
end;
function TLaunchProjects.LaunchAll(AProcesses: TTikeProcessList): Boolean;
var
p: TLaunchProject;
begin
Result := False;
// Hand off files to existing processes, based on project folder
for p in Self do
begin
if not p.PassToRunningProcess(AProcesses) then
begin
// If there isn't an existing process, then we need to launch a new
// process for the project
if not Result and ReserveStartupProject then
begin
// For performance, we'll take the first project for the current process
// if requested
p.FStatus := lpsCurrentInstance;
Result := True;
end
else
begin
p.LaunchAsNewInstance;
end;
end;
end;
end;
end.

View file

@ -0,0 +1,87 @@
unit Keyman.Developer.System.Main;
interface
uses
System.SysUtils,
System.Win.ComObj,
Vcl.Forms,
Winapi.ActiveX,
Winapi.UxTheme,
uCEFApplication,
uCEFTypes,
Keyman.Developer.System.TikeCommandLine,
Keyman.System.CEFManager,
Keyman.System.KeymanSentryClient,
Sentry.Client,
Sentry.Client.Vcl,
UfrmMain,
UmodWebHttpServer;
procedure RunKeymanDeveloper;
implementation
const
LOGGER_DEVELOPER_IDE_TIKE = TKeymanSentryClient.LOGGER_DEVELOPER_IDE + '.tike';
procedure RunWithExceptionsHandled; forward;
procedure RunKeymanDeveloper;
begin
CoInitFlags := COINIT_APARTMENTTHREADED;
Application.MainFormOnTaskBar := True;
Application.Initialize;
Application.Title := 'Keyman Developer';
TKeymanSentryClient.Start(TSentryClientVcl, kscpDeveloper, LOGGER_DEVELOPER_IDE_TIKE);
try
try
RunWithExceptionsHandled;
except
on E:Exception do
SentryHandleException(E);
end;
finally
TKeymanSentryClient.Stop;
end;
end;
procedure RunWithExceptionsHandled;
begin
FInitializeCEF := TCEFManager.Create;
try
if GlobalCEFApp.ProcessType = ptBrowser then
begin
// We want to process the command line only if we are not a CEF
// sub-process, because otherwise we lose the benefit of
if TikeCommandLine.Process = pclExit then
begin
Exit;
end;
end;
if FInitializeCEF.Start then
begin
InitThemeLibrary;
SetThemeAppProperties(STAP_ALLOW_NONCLIENT or STAP_ALLOW_CONTROLS or STAP_ALLOW_WEBCONTENT);
Application.CreateForm(TmodWebHttpServer, modWebHttpServer);
try
Application.CreateForm(TfrmKeymanDeveloper, frmKeymanDeveloper);
try
Application.Run;
finally
FreeAndNil(frmKeymanDeveloper);
end;
finally
FreeAndNil(modWebHttpServer);
end;
end;
finally
FInitializeCEF.Free;
end;
end;
end.

View file

@ -11,23 +11,6 @@ uses
type
TProcessCommandLine = (pclRun, pclExit);
type
TLaunchProject = class
strict private
FProjectFilename: string;
FFilenames: TStringList;
public
constructor Create(const AProjectFilename: string);
destructor Destroy; override;
property ProjectFilename: string read FProjectFilename;
property Filenames: TStringList read FFilenames;
end;
TLaunchProjects = class(TObjectList<TLaunchProject>)
public
function Find(const ProjectFilename: string): TLaunchProject;
end;
type
TTikeCommandLine = class
strict private
@ -35,9 +18,8 @@ type
private
FStartupProjectPath: string;
FStartupFilenames: TArray<string>;
function PassProjectToRunningProcess(const project: TLaunchProject): Boolean;
function LaunchNewInstance(const project: TLaunchProject): Boolean;
function ProcessSubProcess: Boolean;
function GetFilenamesFromCommandLine(filenames: TStringList): Boolean;
public
constructor Create;
destructor Destroy; override;
@ -53,29 +35,14 @@ implementation
uses
System.SysUtils,
Vcl.Dialogs,
Winapi.Windows,
ErrorControlledRegistry,
Keyman.Developer.System.ProjectOwningFile,
Keyman.Developer.System.LaunchProjects,
RegistryKeys,
utilexecute;
{ TLaunchProject }
constructor TLaunchProject.Create(const AProjectFilename: string);
begin
inherited Create;
FProjectFilename := AProjectFilename;
FFilenames := TStringList.Create;
end;
destructor TLaunchProject.Destroy;
begin
FFilenames.Free;
inherited Destroy;
end;
{ TTikeCommandLine }
constructor TTikeCommandLine.Create;
@ -112,17 +79,73 @@ begin
inherited Destroy;
end;
function TTikeCommandLine.GetFilenamesFromCommandLine(filenames: TStringList): Boolean;
var
filename, missingFilenames: string;
NoMoreSwitches: Boolean;
i: Integer;
begin
missingFilenames := '';
NoMoreSwitches := False;
for i := 1 to ParamCount do
begin
if not NoMoreSwitches and ParamStr(i).StartsWith('-') then
begin
// We will treat all `-x` and `--x` parameters as command-line switches,
// which provides forward compatibility for when we want to support
// additional switches. A single `--` parameter tells us that remaining
// parameters are filenames, even if they start with `-`.
if ParamStr(i) = '--' then
NoMoreSwitches := True;
Continue;
end;
filename := ExpandFileName(ParamStr(i));
if FileExists(filename)
then filenames.Add(filename)
else missingFilenames := missingFilenames + '• ' + filename + #13#10;
end;
if missingFilenames <> '' then
begin
ShowMessage('The following file(s) could not be found:'#13#10+missingFilenames);
if filenames.Count = 0 then
// If we only had bogus filenames passed in then we should abort
Exit(False);
end;
Result := True;
end;
function TTikeCommandLine.ProcessSubProcess: Boolean;
var
i: Integer;
begin
if (ParamStr(1) <> '--sub-process') or (ParamCount < 2) then
begin
Exit(False);
end;
// TODO(lowpri): Consider error handling
FStartupProjectPath := ParamStr(2);
SetLength(FStartupFilenames, ParamCount - 2);
for i := 3 to ParamCount do
begin
FStartupFilenames[i-3] := ParamStr(i);
end;
Result := True;
end;
/// Reads filenames passed on command line, and determines if they should be
/// opened in an existing instance of Keyman Developer or in this instance, or
/// even in multiple new instances
function TTikeCommandLine.Process: TProcessCommandLine;
var
i: Integer;
filename: string;
filenames: TStringList;
newProjects, launchProjects: TLaunchProjects;
p: TLaunchProject;
projectFilename: string;
projects: TLaunchProjects;
begin
// If launched by LaunchNewInstance then we'll process that and continue
if ProcessSubProcess then
@ -131,14 +154,11 @@ begin
end;
filenames := TStringList.Create;
launchProjects := TLaunchProjects.Create;
newProjects := TLaunchProjects.Create(False);
projects := TLaunchProjects.Create(True);
try
// TODO: are there any other parameters passed to TIKE?
for i := 1 to ParamCount do
begin
filenames.Add(ExpandFileName(ParamStr(i)));
end;
// Collect filenames passed in on command line
if not GetFilenamesFromCommandLine(filenames) then
Exit(pclExit);
if filenames.Count = 0 then
begin
@ -153,101 +173,27 @@ begin
Exit(pclRun);
end;
for filename in filenames do
// Sort filenames into project groupings
projects.GroupFilenamesIntoProjects(filenames);
// Launch new processes or load files into existing processes
if not projects.LaunchAll(FProcesses)
then Result := pclExit
else Result := pclRun;
if Assigned(projects.StartupProject) then
begin
projectFilename := FindOwnerProjectForFile(filename);
p := launchProjects.Find(projectFilename);
if p = nil then
begin
p := TLaunchProject.Create(projectFilename);
launchProjects.Add(p);
end;
p.Filenames.Add(filename);
end;
// First, hand off files to existing processes, based on project folder
for p in launchProjects do
begin
if not PassProjectToRunningProcess(p) then
begin
newProjects.Add(p);
end;
end;
// If we've already passed all filenames off, then we're done
if newProjects.Count = 0 then
begin
Exit(pclExit);
end;
// If there's more than one project left, then we need to launch new
// processes for each one; we'll take the first for ourselves
FStartupFilenames := newProjects[0].Filenames.ToStringArray;
FStartupProjectPath := newProjects[0].ProjectFilename;
newProjects.Delete(0);
for p in newProjects do
begin
LaunchNewInstance(p);
// LaunchAll will leave one startup project for this current process
// instance to load
FStartupFilenames := projects.StartupProject.Filenames.ToStringArray;
FStartupProjectPath := projects.StartupProject.ProjectFilename;
end;
finally
projects.Free;
filenames.Free;
end;
Result := pclRun;
end;
function TTikeCommandLine.LaunchNewInstance(const project: TLaunchProject): Boolean;
var
filename, cmdline: string;
begin
cmdline := '"'+ParamStr(0)+'" --sub-process "'+project.ProjectFilename+'"';
for filename in project.Filenames do
cmdline := cmdline + ' "'+filename+'"';
Result := TUtilExecute.Execute(cmdline, GetCurrentDir, SW_SHOWNORMAL);
end;
function TTikeCommandLine.ProcessSubProcess: Boolean;
var
i: Integer;
begin
if (ParamStr(1) <> '--sub-process') or (ParamCount < 2) then
begin
Exit(False);
end;
// TODO: Consider error handling
FStartupProjectPath := ParamStr(2);
SetLength(FStartupFilenames, ParamCount - 2);
for i := 3 to ParamCount do
begin
FStartupFilenames[i-3] := ParamStr(i);
end;
Result := True;
end;
function TTikeCommandLine.PassProjectToRunningProcess(const project: TLaunchProject): Boolean;
var
tp: TTikeProcess;
filename: string;
begin
for tp in FProcesses do
begin
if tp.OwnsProject(project.ProjectFilename) then
begin
Result := True;
for filename in project.Filenames do
begin
tp.OpenFile(filename);
end;
Exit;
end;
end;
Result := False;
end;
var
@ -260,20 +206,6 @@ begin
Result := FInstance;
end;
{ TLaunchProjects }
function TLaunchProjects.Find(const ProjectFilename: string): TLaunchProject;
begin
for Result in Self do
begin
if Result.ProjectFilename = ProjectFilename then
begin
Exit;
end;
end;
Result := nil;
end;
initialization
finalization
FreeAndNil(FInstance);

View file

@ -133,13 +133,6 @@ end;
function TTikeProcess.OpenFile(const filename: string): Boolean;
begin
if SameFileName(filename, ProjectFilename) then
begin
// We always accept opening the project that is already open,
// so just bring the process to the front
Exit(FocusProcess);
end;
if not TCopyDataHelper.SendData(0, WindowHandle, TCopyDataCommand.CD_OPENFILE, filename) then
begin
// The target app did not receive the data

View file

@ -387,8 +387,8 @@ type
{ IDropTarget }
procedure Drop(const FileNames: array of string);
function DropAllowed(const FileNames: array of string): Boolean;
procedure Drop(const FileNames: TArray<string>);
function DropAllowed(const FileNames: TArray<string>): Boolean;
procedure InitDock;
procedure LoadDockLayout;
procedure SaveDockLayout;
@ -445,7 +445,7 @@ type
function OpenEditor(FFileName: string; frmClass: TfrmTikeEditorClass): TfrmTikeEditor;
function OpenFile(FFileName: string; FCloseNewFile: Boolean): TfrmTikeChild;
procedure OpenFileInProject(FFileName: string);
procedure OpenFilesInProject(FFileNames: TArray<string>);
procedure HelpTopic(s: string); overload;
procedure HelpTopic(Sender: TTIKEForm); overload;
@ -481,6 +481,7 @@ uses
Keyman.System.KeymanSentryClient,
Keyman.Developer.UI.TikeOnlineUpdateCheck,
GlobalProxySettings,
Keyman.Developer.System.LaunchProjects,
Keyman.Developer.System.Project.ProjectFile,
Keyman.Developer.System.Project.ProjectFileType,
Keyman.Developer.System.Project.WelcomeRenderer,
@ -488,6 +489,7 @@ uses
Keyman.Developer.System.Project.ProjectLog,
Keyman.Developer.System.Project.XmlLdmlProjectFile,
Keyman.Developer.System.TikeCommandLine,
Keyman.Developer.System.TikeMultiProcess,
Keyman.Developer.UI.Project.ProjectFileUI,
Keyman.Developer.UI.Project.ProjectUI,
Keyman.Developer.UI.UfrmLdmlKeyboardEditor,
@ -1207,12 +1209,55 @@ begin
PostMessage(TfrmTikeChild(pages.Pages[Index].Tag).Handle, WM_CLOSE, 0, 0);
end;
procedure TfrmKeymanDeveloper.OpenFileInProject(FFileName: string);
procedure TfrmKeymanDeveloper.OpenFilesInProject(FFileNames: TArray<string>);
var
filename: string;
filenames: TStringList;
projects: TLaunchProjects;
p: TLaunchProject;
FProcesses: TTikeProcessList;
begin
if Length(FFileNames) = 0 then
Exit;
// Uses the TTikeCommandLine pattern to open the resulting file either locally
// or in a remote process
// TODO
OpenFile(FFileName, True);
FProcesses := TikeMultiProcess.Enumerate;
projects := TLaunchProjects.Create(False);
filenames := TStringList.Create;
try
filenames.AddStrings(FFileNames);
FFileNames := [];
// Sort filenames into project groupings
projects.GroupFilenamesIntoProjects(filenames);
// Look for the current instance's project in the list of projects, and
// capture its list of filenames separately
if IsGlobalProjectUIReady then
begin
for p in projects do
begin
if SameFileName(p.ProjectFilename, FGlobalProject.FileName) then
begin
FFileNames := p.Filenames.ToStringArray;
projects.Remove(p);
Break;
end;
end;
end;
// Launch new processes or load files into existing processes
projects.LaunchAll(FProcesses);
finally
filenames.Free;
projects.Free;
FProcesses.Free;
end;
// Finally, open the remaining files which belong to this instance's project
for filename in FFileNames do
OpenFile(filename, True);
end;
function TfrmKeymanDeveloper.OpenFile(FFileName: string; FCloseNewFile: Boolean): TfrmTikeChild;
@ -1253,6 +1298,12 @@ begin
if ext = Ext_ProjectSource then
begin
if SameFileName(GetGlobalProjectUI.FileName, FFileName) then
begin
ShowProject;
Exit;
end;
if BeforeOpenProject then
modActionsMain.OpenProject(FFileName);
end
@ -1420,23 +1471,19 @@ end;
procedure TfrmKeymanDeveloper.mnuFileRecentFileClick(Sender: TObject);
begin
with Sender as TMenuItem do
OpenFileInProject(Hint);
OpenFilesInProject([Hint]);
end;
{-------------------------------------------------------------------------------
- Drag and Drop functionality -
-------------------------------------------------------------------------------}
procedure TfrmKeymanDeveloper.Drop(const FileNames: array of string);
var
i: Integer;
procedure TfrmKeymanDeveloper.Drop(const FileNames: TArray<string>);
begin
for i := Low(Filenames) to High(Filenames) do
OpenFileInProject(Filenames[i]);
OpenFilesInProject(FileNames);
end;
function TfrmKeymanDeveloper.DropAllowed(
const FileNames: array of string): Boolean;
function TfrmKeymanDeveloper.DropAllowed(const FileNames: TArray<string>): Boolean;
begin
Result := True;
end;

View file

@ -440,9 +440,9 @@ begin
pf := SelectedProjectFile;
if Assigned(pf) then (pf.UI as TProjectFileUI).DefaultEvent(Self) // I4687
else if SelectedMRUFileName <> '' then
frmKeymanDeveloper.OpenFileInProject(SelectedMRUFileName)
frmKeymanDeveloper.OpenFilesInProject([SelectedMRUFileName])
else if Params.Values['name'] <> '' then
frmKeymanDeveloper.OpenFileInProject(Params.Values['name']);
frmKeymanDeveloper.OpenFilesInProject([Params.Values['name']]);
end
else if Command = 'viewfilesource' then
begin

View file

@ -293,7 +293,9 @@ uses
Keyman.Developer.System.MultiProcess in 'main\Keyman.Developer.System.MultiProcess.pas',
Keyman.Developer.System.TikeMultiProcess in 'main\Keyman.Developer.System.TikeMultiProcess.pas',
Keyman.System.CopyDataHelper in 'main\Keyman.System.CopyDataHelper.pas',
Keyman.Developer.System.ProjectOwningFile in 'main\Keyman.Developer.System.ProjectOwningFile.pas';
Keyman.Developer.System.ProjectOwningFile in 'main\Keyman.Developer.System.ProjectOwningFile.pas',
Keyman.Developer.System.Main in 'main\Keyman.Developer.System.Main.pas',
Keyman.Developer.System.LaunchProjects in 'main\Keyman.Developer.System.LaunchProjects.pas';
{$R *.RES}
{$R ICONS.RES}
@ -304,46 +306,6 @@ uses
// If you don't add this flag the rederer process will crash when you try to load large images.
{$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE}
const
LOGGER_DEVELOPER_IDE_TIKE = TKeymanSentryClient.LOGGER_DEVELOPER_IDE + '.tike';
begin
TKeymanSentryClient.Start(TSentryClientVcl, kscpDeveloper, LOGGER_DEVELOPER_IDE_TIKE);
try
case TikeCommandLine.Process of
pclExit:
Exit;
end;
try
CoInitFlags := COINIT_APARTMENTTHREADED;
FInitializeCEF := TCEFManager.Create;
try
if FInitializeCEF.Start then
begin
InitThemeLibrary;
SetThemeAppProperties(STAP_ALLOW_NONCLIENT or STAP_ALLOW_CONTROLS or STAP_ALLOW_WEBCONTENT);
Application.MainFormOnTaskBar := True;
Application.Initialize;
Application.Title := 'Keyman Developer';
Application.CreateForm(TmodWebHttpServer, modWebHttpServer);
try
Application.CreateForm(TfrmKeymanDeveloper, frmKeymanDeveloper);
Application.Run;
finally
FreeAndNil(frmKeymanDeveloper);
FreeAndNil(modWebHttpServer);
end;
end;
finally
FInitializeCEF.Free;
end;
except
on E:Exception do
SentryHandleException(E);
end;
finally
TKeymanSentryClient.Stop;
end;
RunKeymanDeveloper;
end.

View file

@ -582,6 +582,8 @@
<DCCReference Include="main\Keyman.Developer.System.TikeMultiProcess.pas"/>
<DCCReference Include="main\Keyman.System.CopyDataHelper.pas"/>
<DCCReference Include="main\Keyman.Developer.System.ProjectOwningFile.pas"/>
<DCCReference Include="main\Keyman.Developer.System.Main.pas"/>
<DCCReference Include="main\Keyman.Developer.System.LaunchProjects.pas"/>
<None Include="Profiling\AQtimeModule1.aqt"/>
<BuildConfiguration Include="Debug">
<Key>Cfg_2</Key>