Introduction
Users of the Concurrent Versions System (CVS) who travel may find themselves battling with the content of the CVS/Root files that exist in their source tree. Because the host name used to resolve a computer can change from one LAN to another, the value stored in the CVS/Root file must reflect that. This article describes the process of building a visual tool to change the CVS/Roots' file contents and, when finished, revert them to their original state.
This essay adopts a more conversational than pedantic tone. However, I hope that some readers still find this narrative instructive. It ranges from abstract topics like problem analysis to detailed solutions like embedding images into an assembly and using them in a TreeView
. The code samples in the article, as well as in the source code, have a lot of comments. If you reside in the camp that we, as developers, should write self-commenting code, then I hope that you can tolerate the excessive //s.
Once upon a time...
On a warmer-than-usual winter morning, I sit on a rather comfortable bench just beyond the doors of the building in which I had a small, borrowed office. My espresso steams quietly in the thin air as I contemplate my problem: I have to figure out a way to temporarily change how the CVS client on my laptop connects to my CVS server. Normally, I connect to my CVS server using SSH tunnels. However, outside my firewall, I have to use my SSH tunnel for piping my IMAP connection.
This worry really doesn't constitute a huge problem. My CVS client, WinCVS, has a macro built into it that will do exactly what I need. However, if I run it, the settings become permanent. Until I change them back again. And, since I'm lazy and prone to forget things like that, I wanted a different solution. The subject of this article consists of the process of developing that solution.
A brief description of CVS and its files
If you know CVS and about the Root file, then you can probably skip to the next section. Otherwise, please let me explain.
From the Wikipedia: Concurrent Versions System, "[CVS] keeps track of all work and all changes in a set of files, typically the implementation of a software project, and allows several (potentially widely separated) developers to collaborate". As a contractor, I use CVS to track all of the changes for my clients' code, as well as allowing other developers on my projects to develop on the same code base.
When you get the code from CVS using the normal check-out process, your CVS client will most likely make subdirectories in all of the directories in the code's source tree. In those subdirectories, aptly named "CVS," the client will create files that describe the files retrieved, the name of the module from which it retrieved them, and (most importantly for this article's existence), a file named "Root" that contains the connection information for the CVS client to automatically reconnect to the CVS server to get file updates. In the image to the left of this section, you can see a cropped view of my Window Explorer for some directories that have code that I checked out from my CVS server. Those subdirectories named "CVS" under the "Test Area" and "Test Area/deader" directories got created by my CVS client when I checked out the source files. Though you can't see it in the tree view, those "CVS" subdirectories have the "hidden
" attribute set.
Inside those "CVS" subdirectories exists the Root file. As stated in the previous paragraph, the Root file contains the connection information for the CVS client to communicate with the CVS server. Normally, the Root files in my "CVS" subdirectories contain the string :ssh:curtis@cvs.grayiris.com:/var/cvs, which means "Use SSH to connect to the CVS server at cvs.grayiris.com with the user name 'curtis' and look in the path /var/cvs for the source code tree."
As stated in the last paragraph, the Root file in my CVS-controlled directories contains the string :ssh:curtis@cvs.grayiris.com:/var/cvs. However, because I have already used the SSH connection to open tunnels for my IMAP connection, I have to tunnel the CVS client's SSH connection through the existing SSH connection. I do that by forwarding requests on my localhost at port 22 to cvs.grayiris.com:22. So, I have to change the content of all my Root files to :ssh:curtis@localhost:/var/cvs. Then, when I finish my work, I'd like them to revert to their normal state without my interference. I'd like this to happen without creating backup files. I'm just not a big fan of them. I don't like my file system to get cluttered.
Furthermore, I don't want to spend a lot of time writing this tool. I mean, if it takes a huge investment of my time, then I can just live with running macros or writing a Perl script to take care of it and ignoring the temporary files that got created.
I get out my notebook and quickly write the requirements in a list:
- Short development time: 1 or 2 hours at most.
- Automatic reversion of CVS/Root files' content to old connection string.
- Capability to optionally, recursively descend through directories to change all CVS/Root files where they exist.
- An efficient tree view of the file system that contains at the top level:
- The "My Documents" folder.
- All local, logical, non-removable drives.
- Tree view must distinguish CVS-controlled directories in its presentation.
I also sketch how I want the user interface to look. The next image contains a reproduction of that sketch using computer drawing tools:
Analysis and design
I really want to get something done on this tool before I have to work. I turn the page of my notebook and sit back on the bench. I glance at my watch and note that I have a couple of more minutes before I need to go inside.
I decide to draw a quick flow chart to model the code's process of changing the CVS/Root files' contents. I come up with the flow chart in the following image:
When looking at the tree view, I decide that a load-on-demand scheme would work the best for me. I don't want to have to walk the entire directory structure to populate the TreeView
that will represent the paths available to me. Starting with the root nodes of the "My Documents" directory and the local drives, the TreeView
would only contain the nodes that the user has actively expanded.
After that, I walk into the building for work.
Lunch time
What does a software developer do during lunch? Develop software! In the morning I thought about this little project. It interests me. While others go off to their restaurants and microwaves, I take my laptop out to that bench where all this had started. I have some good ideas in writing, and a flow chart to help me code. I fire up Visual StudioTM and get to work.
I create a "Windows Application" project and build the user interface first. I really hate applications that don't resize well, so I take the time to ensure that this one will. The following image shows the layout of the items on my main form. With those attributes set, if I maximize the window on my widescreen laptop, everything will resize appropriately.
Now, I have three real pieces of code to complete: the routine that manages the TreeView
, the code that will modify the CVS/Root files, and the code that will revert the files.
Loading the TreeView
I decide to initially address issue four from my list above. Initially populating the TreeView
must happen when the application begins. Since most of my work resides in the "My Documents" directory, I decide to add that first.
Since I decided earlier that I would create the view of the directory structure with a load-on-demand scheme, I need a way to store the path that a TreeNode
represents, as well as if the application has already loaded the child nodes, if they exist. At the bottom of my form's class declaration, I create the following structure to hold that information:
private struct NodeInfo
{
public NodeInfo( bool init, string path )
{
Initialized = init;
Path = path;
}
public bool Initialized;
public string Path;
}
Adding the "My Documents" TreeNode
Then, after the InitializeComponent
method call in the constructor of the form, I add the following lines. Note that the TreeView
control has the name dirTree
:
dirTree.Font = new Font( "Courier New", 10.0f );
bold = new Font( dirTree.Font.FontFamily.Name,
dirTree.Font.Size, FontStyle.Bold );
string myDocDir =
Environment.GetEnvironmentVariable( "USERPROFILE" ) + "\\My Documents";
if( Directory.Exists( myDocDir ) )
{
TreeNode myDocNode = new TreeNode( "My Documents" );
if( Directory.GetDirectories( myDocDir ).Length > 0 )
{
myDocNode.Nodes.Add( new TreeNode( "Loading..." ) );
myDocNode.Tag = new NodeInfo( false, myDocDir );
}
dirTree.Nodes.Add( myDocNode );
}
Adding the logical drives' TreeNodes
I want to only add logical, non-removable drives to my TreeView
. The method Environment.GetLogicalDrives()
returns all of the logical drives without any drive type information. For a moment, I think that I have no alternative but to add all of them. Then, I remember the Windows Management Instrumentation interface that I used in some scripts that I wrote last year. I look in the .NET documentation and find that the System.Management
assembly has what I need. I add a reference to it in my project, add a using System.Management
directive to the file, and add the following code after the code that I added in the last section:
ManagementClass c = new ManagementClass( "Win32_LogicalDisk" );
ManagementObjectCollection moc = c.GetInstances();
foreach( ManagementObject mo in moc )
{
try
{
if( mo[ "DriveType" ] != null &&
Int32.Parse( mo[ "DriveType" ].ToString() ) == 3 )
{
string title =
mo[ "Name" ].ToString() + Path.DirectorySeparatorChar;
TreeNode tn = new TreeNode( title );
if( Directory.GetDirectories( title ).Length > 0 )
{
tn.Nodes.Add( new TreeNode( "Loading..." ) );
tn.Tag = new NodeInfo( false, title );
}
dirTree.Nodes.Add( tn );
}
}
catch( Exception ) {}
}
I build the project and check out what I've done. Sure enough, the TreeView
has the appropriate nodes in it with the appropriate expansion capabilities.
Adding the Load-On-Demand TreeView expansion
Of course, when I expand the root nodes in my TreeView
, I only see a node that reads "Loading...". Alas, I have to do some more work. I look at the documentation for the TreeView
control and note that the AfterExpand
event meets my requirements. After the last code that I added to the constructor, I add the following line and allow Visual Studio to create the appropriate method for me:
dirTree.AfterExpand +=
new TreeViewEventHandler( dirTree_AfterExpand );
Down in dirTree_AfterExpand
, I populate it with the following code:
private void dirTree_AfterExpand( object sender, TreeViewEventArgs e )
{
NodeInfo ni = ( NodeInfo ) e.Node.Tag;
if( !ni.Initialized )
{
e.Node.Nodes.Clear();
string[] dirs = null;
try
{
dirs = Directory.GetDirectories( ni.Path );
}
catch( DirectoryNotFoundException )
{
MessageBox.Show(
"That directory no longer exists and I will remove it from the tree.",
"Invalid Directory",
MessageBoxButtons.OK,
MessageBoxIcon.Exclamation );
e.Node.Remove();
return;
}
for( int i = 0; i < dirs.Length; i++ )
{
try
{
DirectoryInfo di = new DirectoryInfo( dirs[ i ] );
if( ( di.Attributes & FileAttributes.System ) == 0 &&
( di.Attributes & FileAttributes.Hidden ) == 0 )
{
int lastDirSepChar =
dirs[ i ].LastIndexOf( Path.DirectorySeparatorChar );
string nodeTitle = dirs[ i ].Substring( lastDirSepChar + 1 );
TreeNode n = new TreeNode( nodeTitle );
if( CountSubDirs( Directory.GetDirectories( dirs[ i ] ) ) > 0 )
{
n.Nodes.Add( new TreeNode( "Loading..." ) );
}
string p = String.Format( "{0}{1}CVS{1}Root",
di.FullName, Path.DirectorySeparatorChar );
if( File.Exists( p ) )
{
n.NodeFont = bold;
}
n.Tag = new NodeInfo( false, dirs[ i ] );
e.Node.Nodes.Add( n );
}
}
catch( Exception ) {}
}
ni.Initialized = true;
e.Node.Tag = ni;
}
}
Now the TreeView
works exactly how I want it to work. I look at my watch and note I only have about 25 minutes left for my lunch break. If I'm going to get this done, I need to step up the pace a little.
Modifying the CVS/Root files
When the application modifies a file, it needs to remember to which file it did what. Then, it needs to put an item in the ListBox
to show what it did. I look at the documentation for the ListBox.Add
method and see that it accepts any old System.Object
. The ListBox
then uses the Object
's ToString
method to display the entry. I scroll down to the bottom of my class' declaration and type in the following structure definition:
private struct PathSelectInfo
{
public PathSelectInfo( string path, string root )
{
Root = root.Trim();
Path = path;
}
public override string ToString()
{
return Path + " (" + Root + ")";
}
public string Root;
public string Path;
}
Now, that I have a way to store information regarding the modified file, something needs to happen when I click the "Apply" button. Luckily, I spent the time earlier in the day defining the process in my flow chart. I double click on the button in my "Form View," Visual Studio adds the btnApply_Click
event handler, and I add the following code to it:
private void btnApply_Click( object sender, System.EventArgs e )
{
cbRecurse.Enabled = false;
btnApply.Enabled = false;
btnRevertAll.Enabled = false;
NodeInfo ni = ( NodeInfo ) dirTree.SelectedNode.Tag;
ChangeCvsRoot( ni.Path );
cbRecurse.Enabled = true;
btnApply.Enabled = true;
btnRevertAll.Enabled = true;
}
I don't do any file modification in the method because, if the user has chosen to descend into the subdirectories, then I will need a function that can get recursively called to perform those actions. Hence, I define the ChangeCvsRoot
method:
private void ChangeCvsRoot( string path )
{
try
{
string cvsPath = String.Format( "{0}{1}CVS{1}Root", path,
Path.DirectorySeparatorChar );
StreamReader sr = File.OpenText( cvsPath );
string root = sr.ReadToEnd();
sr.Close();
StreamWriter sw = new StreamWriter( cvsPath );
sw.Write( txtRoot.Text + Environment.NewLine );
sw.Close();
lbFilePaths.Items.Add( new PathSelectInfo( path, root ) );
if( cbRecurse.Checked )
{
string[] paths = Directory.GetDirectories( path );
foreach( string p in paths )
{
ChangeCvsRoot( p );
}
}
}
catch( Exception ) {}
}
Now, when I type in a new Root value, select a CVS-controlled directory from the TreeView
, and click the "Apply" button, everything "Just Works"TM.
Reverting the files
Of course, I want to change the files back to their original form. Luckily, I have all that information stored in the ListBox
! So, I double-click the "Revert All" button in the form view, Visual Studio creates the event handler btnRevertAll_Click
and I fill it:
private void btnRevertAll_Click( object sender, System.EventArgs e )
{
for( int i = lbFilePaths.Items.Count - 1; i >= 0; i-- )
{
try
{
PathSelectInfo psi = ( PathSelectInfo ) lbFilePaths.Items[ i ];
string p = String.Format( "{0}{1}CVS{1}Root", psi.Path,
Path.DirectorySeparatorChar );
StreamWriter sw = new StreamWriter( p );
sw.Write( psi.Root + Environment.NewLine );
sw.Close();
lbFilePaths.Items.RemoveAt( i );
}
catch( Exception ) {}
}
}
And, that's it. My utility works! I walk back inside the building with a satisfied smile on my face.
Making it pretty
Later that night...
After getting the kids off to bed, my wife and I relax out on the back porch. I have my laptop out there and show her my new program. She does not write software, nor does she really have an interest in CVS, C#, or my firewall woes. She does, however, like that I write little utilities for myself. I really think the world of her. So, her review means something to me. She says, "That's neat. But, it's kind of ugly. And it loads slowly."
Well, I have to agree with her. So, I decided to attack the immediate aesthetic deficiency. I had not put images in the tree and, admittedly, it looks a little peaked. So, I dig into the Tango Icon Library and find four images in the 16x16 size range that work well for me:
- devices/drive-harddisk.png for the logical drives' nodes.
- apps/system-file-manager.png for the "My Documents" node.
- mimetypes/x-directory-normal.png for normal subdirectory nodes.
- mimetypes/x-directory-remote.png for subdirectory nodes under CVS control.
I add them all to my project.
Embedding the images into the assembly and using them
For little utilities such as this one, I like to embed static resources such as those images. It makes the executable larger, but I don't have to worry about the paths to those files anymore. This requires the use of the .NET Reflection functionality. So, I follow these three easy steps:
- Change the image files' "Build Action" property value from "Content" to "Embedded Resource";
- Add the
using System.Reflection
statement to the file; and,
- Write the code to add the images to the tree.
Step 1: Change the image files' build action
As you can see from the screenshot to the right, the system-file-manager.png file has a "Build Action" property in the Properties pane beneath the Solution Explorer pane. Note that I changed the value to "Embedded Resource". This instructs Visual Studio to instruct the compiler to embed this resource into the resulting assembly, an executable in this case. This way, in step 3, I can write the code to extract the image from the executable and use it in the TreeView
.
Step 2: Add a using directive
Pretty self-explanatory: I type using System.Reflection
at the top of my class' file.
Step 3: Using the Embedded Resources
To use images in a tree, the TreeView
exposes the TreeView.ImageList
property to which a System.Windows.Forms.ImageList
can get assigned. So, to add my newly found images to the TreeView
, I need to create an ImageList
, add the images to it, assign that list to the tree, and go specify which images to use for the different nodes.
To that end, I go back to the constructor of my form and, after the call to InitializeComponent
, I add the following lines to create the ImageList
and populate it:
ImageList iList = new ImageList();
Assembly a = Assembly.GetExecutingAssembly();
Stream imageStream =
a.GetManifestResourceStream( "CvsRootChanger.drive-harddisk.png" );
iList.Images.Add( Image.FromStream( imageStream ) );
imageStream =
a.GetManifestResourceStream( "CvsRootChanger.system-file-manager.png" );
iList.Images.Add( Image.FromStream( imageStream ) );
imageStream =
a.GetManifestResourceStream( "CvsRootChanger.x-directory-normal.png" );
iList.Images.Add( Image.FromStream( imageStream ) );
imageStream =
a.GetManifestResourceStream( "CvsRootChanger.x-directory-remote.png" );
iList.Images.Add( Image.FromStream( imageStream ) );
dirTree.ImageList = iList;
If you use embedded resources in your assemblies, you can not forget to prepend the namespace to the file name when you want to retrieve it from its embedded nature. You'll see that in the four lines where I add the images to the list; I refer to each as "CvsRootChanger.filename
" because my project has the namespace "CvsRootChanger
".
Now that I have assigned the images to the TreeView
, I must go specify which images to use. I search for all the places where I create a new TreeNode
and specify which image to use by its zero-based index in the ImageList
. I find three applicable instances where I add nodes that don't read "Loading..." and change them:
TreeNode myDocNode = new TreeNode( "My Documents", 1, 1 );
TreeNode tn = new TreeNode( title, 0, 0 );
int lastDirSepChar =
dirs[ i ].LastIndexOf( Path.DirectorySeparatorChar );
string nodeTitle = dirs[ i ].Substring( lastDirSepChar + 1 );
TreeNode n = new TreeNode( nodeTitle, 2, 2 );
Now, I don't create a new TreeNode
for subdirectories under the control of CVS. I had just made them bold. So, I look for the bold assignment and add the instructions to use the fourth image for that node:
n.ImageIndex = n.SelectedImageIndex = 3;
I compile my project and start expanding the tree. Sure enough, the images exist correctly in each node.
Overcoming the slow loading
Nothing says professionalism like a splash screen.
Okay, that's not true. But, whenever I started the application, I had to wait while the logical drives got retrieved and added to the TreeView
. This process could take up to five seconds. I did not like it. So, I decide to use a separate thread to load the main form while a secondary form got displayed to the user. I didn't want to invest a lot of time into this, so I kept the design simple. I get out my notebook, again, and quickly write some pseudo code to model the design.
- Show the loading form.
- When the loading form gets activated, start a thread to load the main form.
- If the thread ends because the main form got loaded correctly,
- Close the loading form.
- Show the main form.
Otherwise,
- Display an error message.
- Display a button to close the form.
I created another Windows Form in my project, set its ControlBox
property to "False
" set the form's Text
property to "Loading Application...", added a Panel
docked to the bottom with a Button
in it, added a Label
to the form docked with the "Fill" setting, and set the Label
's Text
property to "Loading the drive information for the application". The following image shows a screen shot of the form designer view of the form:
Now, I needed a way for the loading form to create a main form that would not disappear after the loading form closed. So, at the end of my main form's class declaration, I added the following line:
public static MainAppForm f;
With that in place, I could now store the form in that static
variable and use it when the loading form closed. Since I planned on using a separate thread for loading the main form, I added the using System.Threading
directive to the top of the loading form's class file. In the loading form's constructor, after the call to InitializeComponent
, I added the following line and allowed Visual Studio to create the event handler for me:
this.Activated += new EventHandler( LoadingForm_Activated );
In the event handler, I wrote the following lines of code:
private void LoadingForm_Activated( object sender, EventArgs e )
{
Foo f = new Foo();
f.f = this;
Thread t = new Thread( new ThreadStart( f.LoadMainAppForm ) );
t.Start();
}
And, last of all, at the end of the class declaration, I needed to create my Foo
class that actually handled loading the application's main form:
private class Foo
{
public LoadingForm f;
public void LoadMainAppForm()
{
try
{
MainAppForm.f = new MainAppForm();
f.Close();
}
catch( Exception e )
{
f.lblMessage.TextAlign = ContentAlignment.TopLeft;
f.lblMessage.Text =
"An exception occurred while loading the application"
+ Environment.NewLine
+ Environment.NewLine
+ "Message: "
+ e.Message;
f.cancelPanel.Height = 40;
}
}
}
Finally, I want the loading form to show first when the application starts. I return to the code for my main form and find the Main
method. I change it according to the following snippet:
[STAThread]
static void Main()
{
Application.Run( new LoadingForm() );
if( MainAppForm.f != null )
{
Application.Run( MainAppForm.f );
}
}
I sat back, took a sip of my tea and showed my wife the effect of the changes that I had made. When she gave me the thumbs up, I knew that I had completed my application.
Request for feedback
Please, at your leisure, if you have an opinion about this article and its associated source files, please feel free to express your pleasure or dissent.
History
- 2005-11-13
- Wrote and submitted the article.
- 2005-11-14
- Changed the width of the
<pre>
tags to better fit in Firefox.